mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge branch 'master' into fix/windows-watcher-path-sep
This commit is contained in:
commit
d3523d804f
242 changed files with 16553 additions and 6402 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@ openspec/
|
|||
go.work*
|
||||
.worktrees/
|
||||
.playwright-mcp/
|
||||
|
||||
# Temp benchmark files
|
||||
zz_*_test.go
|
||||
|
|
@ -70,16 +70,27 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([
|
|||
{artist.PictureSmall, deezerApiPictureSmallSize},
|
||||
}
|
||||
for _, imgData := range possibleImages {
|
||||
if imgData.URL != "" {
|
||||
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: imgData.URL,
|
||||
Size: imgData.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
|
||||
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
|
||||
const deezerEmptyPicturePath = "/images/artist//"
|
||||
|
||||
func isPlaceholderPicture(url string) bool {
|
||||
return strings.Contains(url, deezerEmptyPicturePath)
|
||||
}
|
||||
|
||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,54 @@ var _ = Describe("deezerAgent", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *fakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the real images when the artist has a picture", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
|
||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(HaveLen(2))
|
||||
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
|
||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
|
||||
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
|
||||
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography - Language Fallback", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
|||
var resp agents.AlbumInfo
|
||||
for _, lang := range l.languages {
|
||||
var err error
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
|||
}
|
||||
|
||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -286,22 +286,18 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
|
||||
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||
}
|
||||
|
||||
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
|
||||
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
|
||||
if err != nil {
|
||||
if isLastFMError && lfErr.Code == 6 {
|
||||
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
|
||||
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
|
||||
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
|
|
@ -313,6 +309,12 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
|
|||
|
||||
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||
if err != nil {
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
|
||||
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
|
||||
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,7 +539,10 @@ var _ = Describe("lastfmAgent", func() {
|
|||
URL: "https://www.last.fm/music/Cher/Believe",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
|
||||
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
|
||||
})
|
||||
|
||||
It("returns empty images if no images are available", func() {
|
||||
|
|
@ -558,7 +561,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call returns an error", func() {
|
||||
|
|
@ -566,23 +569,17 @@ var _ = Describe("lastfmAgent", func() {
|
|||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
|
||||
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.fm", func() {
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -613,6 +610,13 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
||||
})
|
||||
|
||||
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
|
||||
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
|
||||
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty list if image is the ignored default image", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -260,7 +260,7 @@ func runImport(ctx context.Context, files []string) {
|
|||
ctx = request.WithUser(ctx, *user)
|
||||
}
|
||||
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
for _, file := range files {
|
||||
absPath, err := filepath.Abs(file)
|
||||
|
|
|
|||
66
cmd/root.go
66
cmd/root.go
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -88,6 +89,9 @@ func runNavidrome(ctx context.Context) {
|
|||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
artworkWorker := CreateArtworkWorker()
|
||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
g.Go(startScanWatcher(ctx))
|
||||
|
|
@ -344,6 +348,68 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
// startArtworkWorker starts the background artwork acquisition worker. It always
|
||||
// runs; the queue is simply empty until something enqueues work into it.
|
||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting artwork worker")
|
||||
return worker.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
|
||||
// recurring stale-absent recheck and prune jobs.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
|
||||
if err := worker.EnqueueStaleAbsentAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
|
||||
}
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
|
||||
backfilled, err := worker.Backfill(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running artwork backfill", err)
|
||||
return nil
|
||||
}
|
||||
if !backfilled {
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
|
||||
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running post-backfill artwork prune", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPluginManager starts the plugin manager, if configured.
|
||||
func startPluginManager(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -82,7 +82,7 @@ func runScanner(ctx context.Context) {
|
|||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
|
|
|||
109
cmd/wire_gen.go
109
cmd/wire_gen.go
|
|
@ -65,25 +65,18 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -91,23 +84,23 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
|
|
@ -121,21 +114,22 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
|
|
@ -146,14 +140,9 @@ func CreatePublicRouter() *public.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
|
|
@ -193,38 +182,22 @@ func CreatePrometheus() metrics.Metrics {
|
|||
func CreateScanner(ctx context.Context) model.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
|
@ -236,6 +209,20 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||
return playbackServer
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
imageStore := artwork.GetImageStore()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
fFmpeg := ffmpeg.New()
|
||||
fileCache := artwork.GetImageCache()
|
||||
worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache)
|
||||
return worker
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -247,7 +234,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
|
|
@ -56,6 +57,7 @@ var allProviders = wire.NewSet(
|
|||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||
wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
|
|
@ -136,6 +138,12 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ type configOptions struct {
|
|||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtworkThrottleBuffered bool
|
||||
DevArtworkWorkerConcurrency int
|
||||
DevArtworkExternalMaxRPS int
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
|
|
@ -1071,6 +1073,12 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
||||
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
|
||||
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
|
||||
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
|
||||
// External RPS gates outbound calls to third-party services (per service); it is bounded by
|
||||
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
|
||||
viper.SetDefault("devartworkexternalmaxrps", 2)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const (
|
|||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
|
||||
// to detect artwork-affecting config changes across restarts.
|
||||
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
|
|
@ -36,6 +39,10 @@ const (
|
|||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkPostBackfillPruneDelay = 10 * time.Minute
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
DefaultEncryptionKey = "just for obfuscation"
|
||||
|
|
@ -80,6 +87,9 @@ const (
|
|||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
||||
// upload folders beside it so Prune's sweep never reaches them.
|
||||
HashedArtworkFolder = "hashed"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,42 @@ func (a *Agents) AgentName() string {
|
|||
return "agents"
|
||||
}
|
||||
|
||||
// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability.
|
||||
type ArtistImageAgent struct {
|
||||
Name string
|
||||
Retriever ArtistImageRetriever
|
||||
}
|
||||
|
||||
// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability.
|
||||
type AlbumImageAgent struct {
|
||||
Name string
|
||||
Retriever AlbumImageRetriever
|
||||
}
|
||||
|
||||
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
||||
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||
var result []ArtistImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok {
|
||||
result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
||||
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
||||
var result []AlbumImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok {
|
||||
result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
|
|
|
|||
|
|
@ -362,6 +362,64 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Image retriever enumeration", func() {
|
||||
var ag *Agents
|
||||
var artistImg, artistImg2 *testImageAgent
|
||||
var albumImg, albumImg2 *testAlbumImageAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
artistImg = &testImageAgent{Name: "artistImg"}
|
||||
artistImg2 = &testImageAgent{Name: "artistImg2"}
|
||||
albumImg = &testAlbumImageAgent{name: "albumImg"}
|
||||
albumImg2 = &testAlbumImageAgent{name: "albumImg2"}
|
||||
Register("artistImg", func(model.DataStore) Interface { return artistImg })
|
||||
Register("artistImg2", func(model.DataStore) Interface { return artistImg2 })
|
||||
Register("albumImg", func(model.DataStore) Interface { return albumImg })
|
||||
Register("albumImg2", func(model.DataStore) Interface { return albumImg2 })
|
||||
Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||
})
|
||||
|
||||
Describe("ArtistImageAgents", func() {
|
||||
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
||||
conf.Server.Agents = "artistImg,noImages,artistImg2"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
result := ag.ArtistImageAgents()
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].Name).To(Equal("artistImg"))
|
||||
Expect(result[0].Retriever).To(BeIdenticalTo(artistImg))
|
||||
Expect(result[1].Name).To(Equal("artistImg2"))
|
||||
Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2))
|
||||
})
|
||||
|
||||
It("is empty when external services are disabled", func() {
|
||||
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AlbumImageAgents", func() {
|
||||
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
||||
conf.Server.Agents = "albumImg,noImages,albumImg2"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
result := ag.AlbumImageAgents()
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].Name).To(Equal("albumImg"))
|
||||
Expect(result[0].Retriever).To(BeIdenticalTo(albumImg))
|
||||
Expect(result[1].Name).To(Equal("albumImg2"))
|
||||
Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2))
|
||||
})
|
||||
|
||||
It("is empty when external services are disabled", func() {
|
||||
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
|
|
@ -497,3 +555,17 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin
|
|||
t.Args = []any{id, name, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
||||
type testAlbumImageAgent struct {
|
||||
name string
|
||||
Images []ExternalImage
|
||||
Err error
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (t *testAlbumImageAgent) AgentName() string { return t.name }
|
||||
|
||||
func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
||||
t.Args = []any{name, artist, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
|
|
|||
100
core/artwork/agent_images.go
Normal file
100
core/artwork/agent_images.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
||||
func externalName(name string) string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return name
|
||||
}
|
||||
return str.Clear(name)
|
||||
}
|
||||
|
||||
// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends
|
||||
// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything.
|
||||
func bestImageURL(imgs []agents.ExternalImage) *url.URL {
|
||||
var best *url.URL
|
||||
var bestSize int
|
||||
for i := range imgs {
|
||||
if imgs[i].URL == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(imgs[i].URL)
|
||||
if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
if best == nil || imgs[i].Size > bestSize {
|
||||
best, bestSize = u, imgs[i].Size
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no
|
||||
// agent succeeded and at least one failed transiently.
|
||||
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) {
|
||||
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
|
||||
switch ar.ID {
|
||||
case consts.UnknownArtistID, consts.VariousArtistsID:
|
||||
return nil, "", false
|
||||
}
|
||||
name := externalName(ar.Name)
|
||||
for _, a := range ag.ArtistImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
u := bestImageURL(imgs)
|
||||
if u == nil {
|
||||
return nil, "", agents.ErrNotFound
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = true
|
||||
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
|
||||
}
|
||||
}
|
||||
return nil, "", extErr
|
||||
}
|
||||
|
||||
// fetchAlbumImage is the album counterpart of fetchArtistImage.
|
||||
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
|
||||
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
|
||||
for _, a := range ag.AlbumImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
u := bestImageURL(imgs)
|
||||
if u == nil {
|
||||
return nil, "", agents.ErrNotFound
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = true
|
||||
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
|
||||
}
|
||||
}
|
||||
return nil, "", extErr
|
||||
}
|
||||
262
core/artwork/agent_images_test.go
Normal file
262
core/artwork/agent_images_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fakeImageAgent is a built-in agent stub implementing both image retrievers; it
|
||||
// records call counts so per-agent ordering and short-circuiting can be asserted.
|
||||
type fakeImageAgent struct {
|
||||
name string
|
||||
imgs []agents.ExternalImage
|
||||
err error
|
||||
artistCalls int
|
||||
albumCalls int
|
||||
gotArtistName string
|
||||
gotAlbumName string
|
||||
// block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent.
|
||||
block chan struct{}
|
||||
// mu guards the call counters: the worker resolves several items concurrently.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *fakeImageAgent) AgentName() string { return f.name }
|
||||
|
||||
func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
|
||||
if f.block != nil {
|
||||
<-f.block
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.artistCalls++
|
||||
f.gotArtistName = name
|
||||
f.mu.Unlock()
|
||||
return f.imgs, f.err
|
||||
}
|
||||
|
||||
func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) {
|
||||
f.albumCalls++
|
||||
f.gotAlbumName = name
|
||||
return f.imgs, f.err
|
||||
}
|
||||
|
||||
// imageAgents registers the fakes as built-in agents and enables them in order. The fakes
|
||||
// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe.
|
||||
func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
|
||||
names := make([]string, 0, len(fakes))
|
||||
for _, f := range fakes {
|
||||
fake := f
|
||||
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
||||
names = append(names, fake.name)
|
||||
}
|
||||
conf.Server.Agents = strings.Join(names, ",")
|
||||
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
}
|
||||
|
||||
var _ = Describe("agent images", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
srv *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
})
|
||||
|
||||
img := func(path string, size int) agents.ExternalImage {
|
||||
return agents.ExternalImage{URL: srv.URL + path, Size: size}
|
||||
}
|
||||
|
||||
Describe("bestImageURL", func() {
|
||||
It("picks the largest-Size URL and skips empty ones", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "http://x/small", Size: 10},
|
||||
{URL: "", Size: 9999},
|
||||
{URL: "http://x/big", Size: 100},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("http://x/big"))
|
||||
})
|
||||
|
||||
It("skips a malformed largest URL and falls back to a valid smaller one", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "http://x/valid", Size: 10},
|
||||
{URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("http://x/valid"))
|
||||
})
|
||||
|
||||
It("returns nil when there is no non-empty URL", func() {
|
||||
Expect(bestImageURL(nil)).To(BeNil())
|
||||
Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil())
|
||||
})
|
||||
|
||||
// Plugins hand these over as free-form strings, and url.Parse accepts them all. An
|
||||
// unfetchable candidate that wins here ends the agent's turn before its valid images run.
|
||||
DescribeTable("skips a candidate that cannot be fetched",
|
||||
func(badURL string) {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: badURL, Size: 100}, // largest, and first
|
||||
{URL: "https://cdn.example.com/ok.jpg", Size: 10},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
|
||||
},
|
||||
Entry("a relative path", "images/big.jpg"),
|
||||
Entry("a root-relative path", "/images/big.jpg"),
|
||||
Entry("a scheme we cannot fetch", "ftp://host/big.jpg"),
|
||||
Entry("a scheme-relative URL", "//host/big.jpg"),
|
||||
Entry("a URL with no host", "http:///big.jpg"),
|
||||
)
|
||||
|
||||
// Size is often 0 for every candidate, and only a strictly larger one replaces the first,
|
||||
// so an unfetchable entry in first position would otherwise stick.
|
||||
It("skips an unfetchable first candidate when every Size is zero", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "images/rel.jpg"},
|
||||
{URL: "https://cdn.example.com/ok.jpg"},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
|
||||
})
|
||||
|
||||
It("returns nil when no candidate is fetchable", func() {
|
||||
Expect(bestImageURL([]agents.ExternalImage{
|
||||
{URL: "images/a.jpg", Size: 10},
|
||||
{URL: "ftp://host/b.jpg", Size: 20},
|
||||
})).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fetchArtistImage", func() {
|
||||
It("returns the first agent's image and its name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(extErr).To(BeFalse())
|
||||
})
|
||||
|
||||
It("skips the external lookup for synthetic artists", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(extErr).To(BeFalse())
|
||||
}
|
||||
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
|
||||
})
|
||||
|
||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
a := &fakeImageAgent{name: "agentA"}
|
||||
ag := imageAgents(a)
|
||||
|
||||
_, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"})
|
||||
Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC")))
|
||||
})
|
||||
|
||||
It("falls through to a later agent, and its success beats the earlier error", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: errBreakerOpen}
|
||||
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentB"))
|
||||
Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error")
|
||||
Expect(a.artistCalls).To(Equal(1))
|
||||
Expect(b.artistCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("reports a clean miss when every agent finds nothing", func() {
|
||||
a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found
|
||||
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure")
|
||||
})
|
||||
|
||||
It("reports extErr when one agent fails transiently and the rest find nothing", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
|
||||
b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fetchAlbumImage", func() {
|
||||
It("returns the winning agent's image and name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(a.albumCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("reports extErr when the only agent fails transiently", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("gate naming", func() {
|
||||
It("invokes the gate once per agent, keyed by agent name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
var gatedNames []string
|
||||
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return f()
|
||||
}
|
||||
|
||||
r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(gatedNames).To(Equal([]string{"agentA", "agentB"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,134 +1,394 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
_ "image/gif"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
|
||||
var errStaleSource = errors.New("artwork: source file changed since resolution")
|
||||
|
||||
// Image is one servable artwork response.
|
||||
type Image struct {
|
||||
io.ReadCloser
|
||||
Hash string // pixel identity; "" for placeholders
|
||||
ETag string // representation validator; "" means Hash applies (full-size original)
|
||||
LastUpdated time.Time
|
||||
Placeholder bool
|
||||
}
|
||||
|
||||
// representationTag varies with dimensions and encode settings, so a config change invalidates
|
||||
// a revalidating client's cache even though the pixel hash is unchanged.
|
||||
func representationTag(hash string, size int, square bool) string {
|
||||
return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
|
||||
}
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
// Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
|
||||
// the id resolves to nothing, so the caller can pick placeholder vs 404.
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
|
||||
// GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
|
||||
// kind's placeholder image (never resized, Placeholder=true).
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
|
||||
return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
provider external.Provider
|
||||
// entityExists reports whether the entity an artwork id points at is still there: state rows
|
||||
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
|
||||
func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
|
||||
var found bool
|
||||
var err error
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||
case model.KindAlbumArtwork:
|
||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||
case model.KindMediaFileArtwork:
|
||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||
case model.KindPlaylistArtwork:
|
||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||
case model.KindRadioArtwork:
|
||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||
case model.KindDiscArtwork:
|
||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||
if perr != nil {
|
||||
return false
|
||||
}
|
||||
found, err = ds.Album(ctx).Exists(albumID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return err == nil && found
|
||||
}
|
||||
|
||||
type artworkReader interface {
|
||||
cache.Item
|
||||
LastUpdated() time.Time
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
type service struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
store *ImageStore
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
}
|
||||
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
|
||||
artID, err := s.parseArtworkID(ctx, id)
|
||||
var img *Image
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
img, err = s.Get(ctx, artID, size, square)
|
||||
}
|
||||
// Only a resolvable entity with no art gets a placeholder; an unknown id must stay
|
||||
// ErrNotFound so callers can still answer 404 / Subsonic error 70.
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
} else {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
return reader, consts.ServerStart, nil
|
||||
return placeholderImage(artID.Kind), nil
|
||||
}
|
||||
return reader, lastUpdate, err
|
||||
return img, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
if artID.ID == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
r, err := a.cache.Get(ctx, artReader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
|
||||
}
|
||||
return nil, time.Time{}, err
|
||||
if size < 0 {
|
||||
size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
|
||||
}
|
||||
switch artID.Kind {
|
||||
case model.KindDiscArtwork:
|
||||
return s.serveDisc(ctx, artID, size, square)
|
||||
case model.KindMediaFileArtwork:
|
||||
return s.serveMediaFile(ctx, artID, size, square)
|
||||
default:
|
||||
return s.serveEntity(ctx, artID, size, square)
|
||||
}
|
||||
return r, artReader.LastUpdated(), nil
|
||||
}
|
||||
|
||||
type coverArtGetter interface {
|
||||
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
|
||||
// hammer external services; below staleAbsentAge to catch younger absences.
|
||||
const requestRecheckAge = time.Hour
|
||||
|
||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return s.provisional(ctx, artID, size, square)
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case ia.Hash == "":
|
||||
// Inserts an immediately-eligible recheck for a settled absent row.
|
||||
if time.Since(ia.AttemptedAt) > requestRecheckAge {
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
}
|
||||
return nil, ErrUnavailable
|
||||
default:
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
}
|
||||
}
|
||||
|
||||
// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
|
||||
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
|
||||
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
|
||||
size int, square bool, open func() (io.ReadCloser, error),
|
||||
) (*Image, error) {
|
||||
if size == 0 && !square {
|
||||
rc, err := open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rc == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate}
|
||||
if hash == "" {
|
||||
img.ETag = representationTag(key, size, square)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
stream, err := s.cache.Get(ctx, &resizedItem{
|
||||
hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
|
||||
}
|
||||
|
||||
// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
|
||||
// cancelled request is not: it must not enqueue a re-resolution.
|
||||
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
|
||||
// Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
|
||||
if !entityExists(ctx, s.ds, artID) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return s.dangling(ctx, artID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
|
||||
func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
}
|
||||
log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err)
|
||||
return s.dangling(ctx, artID)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
|
||||
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
|
||||
if isFileBacked(ia.Source) {
|
||||
f, err := os.Open(ia.SourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
|
||||
f.Close()
|
||||
log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
|
||||
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
||||
return nil, errStaleSource
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
// Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
|
||||
if ia.SourcePath != "" && ia.RefMtime != 0 {
|
||||
info, err := os.Stat(ia.SourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.ModTime().UnixNano() != ia.RefMtime {
|
||||
log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
|
||||
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
||||
return nil, errStaleSource
|
||||
}
|
||||
}
|
||||
return store.Open(ia.Hash, mime)
|
||||
}
|
||||
|
||||
// provisional serves local bytes for an entity with no state row, enqueuing the worker but
|
||||
// never writing a state row itself.
|
||||
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
|
||||
res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
|
||||
"source", res.source, "hit", res.reader != nil)
|
||||
return s.serveResolution(ctx, res, size, square)
|
||||
}
|
||||
|
||||
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
|
||||
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
|
||||
if res.reader == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer res.reader.Close()
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
hash, err := hashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
|
||||
return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
|
||||
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
|
||||
}
|
||||
|
||||
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||
if !conf.Server.EnableMediaFileCoverArt {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case err == nil && ia.Hash != "":
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
case err == nil:
|
||||
// absent row: fall through
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
// no row: fall through
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
noRow := errors.Is(err, model.ErrNotFound)
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
|
||||
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
|
||||
}
|
||||
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
|
||||
// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
|
||||
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
|
||||
lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
if !ok {
|
||||
// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
return s.serveResolution(ctx, res, size, square)
|
||||
}
|
||||
|
||||
// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
|
||||
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
dr, err := newDiscArtworkReader(ctx, s.ds, artID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
||||
selectImage := func() (io.ReadCloser, string, error) {
|
||||
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
|
||||
return selectImageReader(ctx, artID, funcs...)
|
||||
}
|
||||
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
||||
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
||||
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
||||
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
|
||||
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, albumArtID, size, square)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
|
||||
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
|
||||
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: artID.Kind.Prefix(),
|
||||
ItemID: artID.ID,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
Priority: priority,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderImage(kind model.Kind) *Image {
|
||||
path := consts.PlaceholderAlbumArt
|
||||
if kind == model.KindArtistArtwork {
|
||||
path = consts.PlaceholderArtistArt
|
||||
}
|
||||
r, _ := resources.FS().Open(path)
|
||||
return &Image{ReadCloser: r, Placeholder: true}
|
||||
}
|
||||
|
||||
type coverArtIDGetter interface {
|
||||
CoverArtID() model.ArtworkID
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
|
||||
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
if id == "" {
|
||||
return model.ArtworkID{}, ErrUnavailable
|
||||
}
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
if err == nil {
|
||||
if artID, err := model.ParseArtworkID(id); err == nil {
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
|
||||
entity, err := model.GetEntityByID(ctx, a.ds, id)
|
||||
entity, err := model.GetEntityByID(ctx, s.ds, id)
|
||||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
if e, ok := entity.(coverArtGetter); ok {
|
||||
artID = e.CoverArtID()
|
||||
if e, ok := entity.(coverArtIDGetter); ok {
|
||||
return e.CoverArtID(), nil
|
||||
}
|
||||
switch e := entity.(type) {
|
||||
case *model.Artist:
|
||||
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
|
||||
case *model.Album:
|
||||
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
|
||||
case *model.MediaFile:
|
||||
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
|
||||
case *model.Playlist:
|
||||
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
|
||||
}
|
||||
return artID, nil
|
||||
return model.ArtworkID{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
case model.KindDiscArtwork:
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindRadioArtwork:
|
||||
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
func unixMtime(mtime int64) time.Time {
|
||||
if mtime <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return artReader, err
|
||||
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,629 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/gen2brain/webp"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
repoRoot, _ := os.Getwd()
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
|
||||
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
|
||||
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
|
||||
|
||||
cache := GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork)
|
||||
})
|
||||
|
||||
Describe("albumArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if album is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
|
||||
})
|
||||
It("returns ErrUnavailable if embed path is not available", func() {
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
DescribeTable("CoverArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.CoverArtPriority = priority
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||
)
|
||||
})
|
||||
Context("LastUpdated", func() {
|
||||
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
|
||||
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
|
||||
// cached cover art when only the image file changes.
|
||||
now := time.Now().Truncate(time.Second)
|
||||
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ar.LastUpdated()).To(Equal(expected))
|
||||
},
|
||||
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||
Entry("equal timestamps", now, now, now),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("discArtworkReader", func() {
|
||||
Context("LastUpdated", func() {
|
||||
// Regression test for #5377: same bug as albumArtworkReader — disc covers
|
||||
// must also revalidate when the image file changes, not only when media files do.
|
||||
now := time.Now().Truncate(time.Second)
|
||||
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
|
||||
})
|
||||
|
||||
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
|
||||
dr, err := newDiscArtworkReader(ctx, aw, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(dr.LastUpdated()).To(Equal(expected))
|
||||
},
|
||||
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||
Entry("equal timestamps", now, now, now),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
folderRepo.result = []model.Folder{{
|
||||
ID: "f1",
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfAnotherWithEmbed,
|
||||
})
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
alSingleDisc,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfWithEmbed,
|
||||
mfWithoutEmbed,
|
||||
mfCorruptedCover,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
})
|
||||
It("returns embed cover if successfully extracted by ffmpeg", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("returns album cover if media file has no cover art", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
|
||||
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to disc art, which itself falls back to album art
|
||||
Expect(path).To(Equal("dc-444:2_0"))
|
||||
})
|
||||
It("falls back to album cover art for single-disc albums even with a disc number", func() {
|
||||
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Single-disc album should skip disc art and go straight to album art
|
||||
Expect(path).To(Equal("al-888_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
Describe("findPlaylistSidecarPath", func() {
|
||||
It("discovers sidecar image next to playlist file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("returns empty string when no sidecar image exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty string when playlist has no path", func() {
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("finds sidecar with different case base name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromPlaylistExternalImage", func() {
|
||||
It("opens local path from ExternalImageURL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(string(data)).To(Equal("external image data"))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
It("returns nil when ExternalImageURL is empty", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: ""},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when local file does not exist", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
|
||||
}
|
||||
r, _, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
func(srcFormat string, expectedFormat string, landscape bool, size int) {
|
||||
coverFileName := "cover." + srcFormat
|
||||
dirName := createImage(srcFormat, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
|
||||
rootLibRepo := &tests.MockLibraryRepo{}
|
||||
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
|
||||
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal(expectedFormat))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", "png", false, 200),
|
||||
Entry("landscape png image", "png", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "png", true, 200),
|
||||
)
|
||||
})
|
||||
When("EnableWebPEncoding is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = true
|
||||
})
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns JPEG if original image is a JPG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
|
||||
rootLibRepo := &tests.MockLibraryRepo{}
|
||||
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
|
||||
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.png"
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("Requested size is larger than original", func() {
|
||||
It("clamps size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 should return at original size
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
|
||||
It("clamps square size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 with square should return 16x16 square
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
|
@ -11,13 +11,26 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestArtwork(t *testing.T) {
|
||||
// Runs unconditionally: the two leaks below are pre-existing and out of this
|
||||
// package's control, so they're ignored by exact top-function instead.
|
||||
defer goleak.VerifyNone(t,
|
||||
goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"),
|
||||
// notify's own init() starts a singleton tree the moment it's imported (via
|
||||
// core/storage/local or plugins); recursive on darwin, nonrecursive on linux.
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"),
|
||||
)
|
||||
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
|
|
@ -25,7 +38,6 @@ func TestArtwork(t *testing.T) {
|
|||
}
|
||||
|
||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||
|
|
@ -69,3 +81,37 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) {
|
|||
}
|
||||
return osDirFS{os.DirFS(s.root)}, nil
|
||||
}
|
||||
|
||||
// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value
|
||||
// answers as an unremarkable library does; the fields drive the album-root lookup and its failures.
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
err error
|
||||
parentResult *model.Folder
|
||||
getErr error
|
||||
getCallCount int
|
||||
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check).
|
||||
// False means the parent qualifies as an album root.
|
||||
hasOtherAudio bool
|
||||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
if f.parentResult != nil {
|
||||
return f.parentResult, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,523 @@
|
|||
package artwork_test
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw artwork.Artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
store *ImageStore
|
||||
imgCache cache.FileCache
|
||||
svc Artwork
|
||||
repoRoot string
|
||||
coverBytes []byte
|
||||
seedEntity func(kind, id string)
|
||||
)
|
||||
|
||||
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
|
||||
|
||||
seedFoundStore := func(kind, id string, imgBytes []byte) string {
|
||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
seedEntity(kind, id)
|
||||
return hash
|
||||
}
|
||||
|
||||
// Without its owning entity, a state row is not served at all.
|
||||
seedEntity = func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
switch kind {
|
||||
case "al":
|
||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
case "mf":
|
||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||
}
|
||||
}
|
||||
|
||||
readAll := func(img *Image) []byte {
|
||||
GinkgoHelper()
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
cache := artwork.GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
conf.Server.DiscArtPriority = "cover.*"
|
||||
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
return arg.(artworkReader).Reader(ctx)
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
svc = NewArtwork(ds, imgCache, store, ffm)
|
||||
})
|
||||
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Describe("found state", func() {
|
||||
It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() {
|
||||
seedFoundStore("al", "al1", coverBytes)
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// A resized response versions its ETag with the encode settings, not the pixel hash.
|
||||
Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false)))
|
||||
Expect(img.ETag).ToNot(Equal(img.Hash))
|
||||
resized := readAll(img)
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(resized))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cfg.Width).To(Equal(100))
|
||||
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Deleting the store file proves the warm entry serves without touching the original.
|
||||
hash, _ := hashImage(bytes.NewReader(coverBytes))
|
||||
Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed())
|
||||
Eventually(func(g Gomega) {
|
||||
img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(readAll(img2)).To(Equal(resized))
|
||||
}).Should(Succeed())
|
||||
})
|
||||
|
||||
Expect(result).To(Equal(phBytes))
|
||||
})
|
||||
It("treats a negative size as a full-size request, not a giant resize", func() {
|
||||
seedFoundStore("al", "alneg", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)")
|
||||
})
|
||||
|
||||
It("streams a file-backed found image at full size", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
mtime := fileMtime(imgPath)
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al2")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||
})).To(Succeed())
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||
})
|
||||
|
||||
It("enforces the mtime rule on the sized (loader) path too", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3b")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
// State rows outlive a deleted entity until the next prune.
|
||||
It("refuses to serve a found row whose entity is gone", func() {
|
||||
hash := seedFoundStore("al", "alzz", coverBytes)
|
||||
Expect(hash).ToNot(BeEmpty())
|
||||
albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("does not re-enqueue a recently-attempted absent state", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("promotes a stale absent state at Bump priority on view", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
|
||||
Describe("provisional read-through", func() {
|
||||
It("serves local folder art, enqueues a Bump, and writes no state row", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
|
||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() {
|
||||
folderRepo.result = nil
|
||||
albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}})
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("media file", func() {
|
||||
It("serves a track's own found art", func() {
|
||||
seedFoundStore("mf", "mf1", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
|
||||
seedFoundStore("al", "albz", coverBytes)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art")
|
||||
})
|
||||
|
||||
It("delegates to the album when the track's state is absent", func() {
|
||||
seedFoundStore("al", "albm", coverBytes)
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")]
|
||||
Expect(mfEnq).To(BeFalse())
|
||||
})
|
||||
|
||||
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
seedFoundStore("al", "albn", coverBytes)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")]
|
||||
Expect(mfEnq).To(BeFalse())
|
||||
})
|
||||
|
||||
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
|
||||
}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delegates a multi-disc track to its disc art, not straight to the album", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}})
|
||||
seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// The disc-folder image, not the album's own art: proof it routed through serveDisc.
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("falls back to the album when an eligible track's embedded art will not extract", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
// HasCoverArt is set, but the file is not audio, so nothing extracts.
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/front.png",
|
||||
}})
|
||||
albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}})
|
||||
seedFoundStore("al", "albad", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover")
|
||||
})
|
||||
|
||||
It("routes a single-disc track through disc resolution too", func() {
|
||||
// DiscArtPriority applies to single-disc albums too, over the album's own found art.
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}})
|
||||
seedFoundStore("al", "alsd", []byte("album-art-distinct"))
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("disc", func() {
|
||||
It("serves a local disc-folder image", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
|
||||
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
// The resize cache keys on id + album mtime, not on the bytes, so dropping the source
|
||||
// between the two requests is what shows a warm hit never touches the filesystem.
|
||||
It("serves a sized disc image from cache without re-reading the source", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
warmed := readAll(first)
|
||||
Expect(warmed).ToNot(BeEmpty())
|
||||
|
||||
folderRepo.result = nil
|
||||
|
||||
second, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source")
|
||||
})
|
||||
|
||||
// A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt.
|
||||
It("invalidates the cached image when the folder's images change", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
|
||||
ImagesUpdatedAt: time.Now().Add(-time.Hour),
|
||||
}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
firstKey := first.ETag
|
||||
readAll(first)
|
||||
|
||||
folderRepo.result[0].ImagesUpdatedAt = time.Now()
|
||||
second, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(second)
|
||||
Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry")
|
||||
})
|
||||
|
||||
// Disc art has no content hash, so without an explicit validator every ETag would be empty.
|
||||
It("gives a full-size disc image a validator that tracks the source", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
|
||||
ImagesUpdatedAt: time.Now().Add(-time.Hour),
|
||||
}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(first)
|
||||
Expect(first.ETag).ToNot(BeEmpty())
|
||||
|
||||
folderRepo.result[0].ImagesUpdatedAt = time.Now()
|
||||
second, err := svc.Get(ctx, discID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(second)
|
||||
Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged")
|
||||
})
|
||||
|
||||
It("falls back to album art when no disc image matches", func() {
|
||||
folderRepo.result = nil
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}})
|
||||
seedFoundStore("al", "aldc2", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetOrPlaceholder", func() {
|
||||
It("accepts a raw entity id and serves its cover art", func() {
|
||||
albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}})
|
||||
seedFoundStore("al", "rawal", coverBytes)
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("falls back to the album placeholder ignoring size and square", func() {
|
||||
img, err := svc.GetOrPlaceholder(ctx, "", 300, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
Expect(img.Hash).To(BeEmpty())
|
||||
Expect(img.LastUpdated).To(BeZero())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, _ := io.ReadAll(ph)
|
||||
Expect(readAll(img)).To(Equal(phBytes))
|
||||
})
|
||||
|
||||
It("falls back to the artist placeholder for an absent artist", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, _ := io.ReadAll(ph)
|
||||
Expect(readAll(img)).To(Equal(phBytes))
|
||||
})
|
||||
|
||||
// "No art" and "no such entity" are different answers: clients 404 only on the latter.
|
||||
It("reports not-found rather than a placeholder for an id with no entity", func() {
|
||||
_, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
_, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func fileMtime(path string) int64 {
|
||||
GinkgoHelper()
|
||||
info, err := os.Stat(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return info.ModTime().UnixNano()
|
||||
}
|
||||
|
||||
var _ = Describe("EntityExists", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||
})
|
||||
|
||||
DescribeTable("reports whether the owning entity is still there",
|
||||
func(id string, expected bool) {
|
||||
Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected))
|
||||
},
|
||||
Entry("existing album", "al-al1", true),
|
||||
Entry("deleted album", "al-gone", false),
|
||||
Entry("existing artist", "ar-ar1", true),
|
||||
Entry("deleted artist", "ar-gone", false),
|
||||
Entry("existing radio", "ra-ra1", true),
|
||||
Entry("deleted radio", "ra-gone", false),
|
||||
// Disc art has no entity of its own; it stands or falls with its album.
|
||||
Entry("disc of an existing album", "dc-al1:1", true),
|
||||
Entry("disc of a deleted album", "dc-gone:1", false),
|
||||
Entry("malformed disc id", "dc-nodiscnum", false),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
||||
// backed by either a real file cache or disabled cache depending on cacheSize.
|
||||
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
||||
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
||||
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
||||
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
||||
//
|
||||
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
||||
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
||||
b.Helper()
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a realistic cover image on disk
|
||||
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
coverImg := generateGradientImage(1000, 1000)
|
||||
f, err := os.Create(coverPath)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
||||
f.Close()
|
||||
b.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = conf.NewDir(tmpDir)
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
// Set up mock data store with album pointing to our cover.
|
||||
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
||||
album := model.Album{
|
||||
ID: "bench-album-1",
|
||||
Name: "Benchmark Album",
|
||||
FolderIDs: []string{"f1"},
|
||||
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
folderRepo := &fakeFolderRepo{
|
||||
result: []model.Folder{{
|
||||
Path: tmpDir,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}},
|
||||
}
|
||||
ds := &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
artID := album.CoverArtID()
|
||||
|
||||
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
})
|
||||
|
||||
// Wait for cache init if enabled
|
||||
if cacheSize != "0" {
|
||||
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
||||
runtime.Gosched() // Yield to allow background init goroutine to run
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
||||
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
||||
|
||||
cleanupAll := func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
return aw, artID, cleanupAll
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2E(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
sizes := []int{0, 300}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm the cache on first call if cache is enabled
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
concurrencyLevels := []int{10, 50}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, n := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm cache
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
||||
if r != nil {
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
|
@ -45,3 +46,11 @@ func generateGradientImage(width, height int) *image.RGBA {
|
|||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders.
|
||||
func gradientNRGBA(size int) *image.NRGBA {
|
||||
src := generateGradientImage(size, size)
|
||||
dst := image.NewNRGBA(src.Bounds())
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
|
|
|||
191
core/artwork/blurhash/blurhash.go
Normal file
191
core/artwork/blurhash/blurhash.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
|
||||
// parameterized to match Jellyfin so clients see equivalent hashes.
|
||||
package blurhash
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
// maxInputSize: larger inputs are slower with no visible difference in the result.
|
||||
const maxInputSize = 128
|
||||
|
||||
// components picks x/y component counts targeting ~16 near-square tiles.
|
||||
func components(width, height int) (int, int) {
|
||||
xf := math.Sqrt(16.0 * float64(width) / float64(height))
|
||||
yf := xf * float64(height) / float64(width)
|
||||
return min(int(xf)+1, 9), min(int(yf)+1, 9)
|
||||
}
|
||||
|
||||
// Encode returns the blurhash of img, deriving the component counts from its aspect ratio.
|
||||
func Encode(img image.Image) (string, error) {
|
||||
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
|
||||
return "", errors.New("blurhash: empty image")
|
||||
}
|
||||
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
|
||||
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
|
||||
src := pixelsOf(downscale(img))
|
||||
w, h := src.w, src.h
|
||||
|
||||
cosX := make([][]float64, xComp)
|
||||
for i := range cosX {
|
||||
cosX[i] = make([]float64, w)
|
||||
for x := range cosX[i] {
|
||||
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
|
||||
}
|
||||
}
|
||||
cosY := make([][]float64, yComp)
|
||||
for j := range cosY {
|
||||
cosY[j] = make([]float64, h)
|
||||
for y := range cosY[j] {
|
||||
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
|
||||
}
|
||||
}
|
||||
|
||||
lin := srgbToLinearTable()
|
||||
factors := make([][3]float64, xComp*yComp)
|
||||
for y := range h {
|
||||
row := src.pix[y*src.stride:]
|
||||
for x := range w {
|
||||
p := x * 4
|
||||
r, g, b := row[p], row[p+1], row[p+2]
|
||||
if src.straight {
|
||||
r, g, b = premultiply(r, g, b, row[p+3])
|
||||
}
|
||||
lr, lg, lb := lin[r], lin[g], lin[b]
|
||||
for j := range yComp {
|
||||
for i := range xComp {
|
||||
basis := cosX[i][x] * cosY[j][y]
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += basis * lr
|
||||
f[1] += basis * lg
|
||||
f[2] += basis * lb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx := range factors {
|
||||
norm := 2.0
|
||||
if idx == 0 {
|
||||
norm = 1.0
|
||||
}
|
||||
scale := norm / float64(w*h)
|
||||
factors[idx][0] *= scale
|
||||
factors[idx][1] *= scale
|
||||
factors[idx][2] *= scale
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
|
||||
|
||||
// Derived counts are at least 1x9, so there is always at least one AC factor.
|
||||
ac := factors[1:]
|
||||
actualMax := 0.0
|
||||
for _, f := range ac {
|
||||
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
|
||||
}
|
||||
quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5))))
|
||||
maxVal := float64(quantMax+1) / 166
|
||||
sb.WriteString(encode83(quantMax, 1))
|
||||
|
||||
dc := factors[0]
|
||||
sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
|
||||
for _, f := range ac {
|
||||
sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
|
||||
type pixels struct {
|
||||
pix []uint8
|
||||
stride int
|
||||
w, h int
|
||||
// straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash
|
||||
// identical to the one an equivalent *image.RGBA produces.
|
||||
straight bool
|
||||
}
|
||||
|
||||
// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts
|
||||
// anything else.
|
||||
func pixelsOf(img image.Image) pixels {
|
||||
b := img.Bounds()
|
||||
switch src := img.(type) {
|
||||
case *image.RGBA:
|
||||
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()}
|
||||
case *image.NRGBA:
|
||||
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true}
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
|
||||
return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()}
|
||||
}
|
||||
|
||||
func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) {
|
||||
if a == 255 {
|
||||
return r, g, b
|
||||
}
|
||||
return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255)
|
||||
}
|
||||
|
||||
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
|
||||
var t [256]float64
|
||||
for i := range t {
|
||||
t[i] = srgbToLinear(i)
|
||||
}
|
||||
return &t
|
||||
})
|
||||
|
||||
func downscale(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxInputSize && h <= maxInputSize {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxInputSize) / float64(max(w, h))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func quantAC(v, maxVal float64) int {
|
||||
return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
|
||||
}
|
||||
|
||||
func signPow(v, exp float64) float64 {
|
||||
return math.Copysign(math.Pow(math.Abs(v), exp), v)
|
||||
}
|
||||
|
||||
func srgbToLinear(v int) float64 {
|
||||
f := float64(v) / 255
|
||||
if f <= 0.04045 {
|
||||
return f / 12.92
|
||||
}
|
||||
return math.Pow((f+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func linearToSRGB(v float64) int {
|
||||
v = min(max(0, v), 1)
|
||||
if v <= 0.0031308 {
|
||||
return int(v*12.92*255 + 0.5)
|
||||
}
|
||||
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
|
||||
}
|
||||
|
||||
// encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
|
||||
func encode83(value, length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := length - 1; i >= 0; i-- {
|
||||
b[i] = alphabet[value%83]
|
||||
value /= 83
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
17
core/artwork/blurhash/blurhash_suite_test.go
Normal file
17
core/artwork/blurhash/blurhash_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package blurhash_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestBlurHash(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "BlurHash Suite")
|
||||
}
|
||||
137
core/artwork/blurhash/blurhash_test.go
Normal file
137
core/artwork/blurhash/blurhash_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package blurhash_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
func decode83(s string) int {
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
v = v*83 + strings.IndexRune(alphabet, c)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func solidImage(w, h int, c color.NRGBA) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func gradientImage(w, h int) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Encode input types", func() {
|
||||
// The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the
|
||||
// premultiplied *image.RGBA it used to receive, or every hash silently shifts.
|
||||
buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) {
|
||||
const size = 40
|
||||
nrgba := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := range size {
|
||||
for x := range size {
|
||||
c := color.NRGBA{
|
||||
R: uint8(255 * x / size), G: uint8(255 * y / size),
|
||||
B: uint8((x + y) * 255 / (2 * size)), A: alpha,
|
||||
}
|
||||
nrgba.SetNRGBA(x, y, c)
|
||||
rgba.Set(x, y, c) // image.RGBA.Set premultiplies
|
||||
}
|
||||
}
|
||||
return nrgba, rgba
|
||||
}
|
||||
|
||||
DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces",
|
||||
func(alpha uint8) {
|
||||
nrgba, rgba := buildPair(alpha)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
},
|
||||
Entry("opaque", uint8(255)),
|
||||
Entry("partly transparent", uint8(128)),
|
||||
Entry("fully transparent, which premultiplication crushes to black", uint8(0)),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Encode", func() {
|
||||
// The size flag encodes (xComp-1) + (yComp-1)*9.
|
||||
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
|
||||
func(w, h, expectedX, expectedY int) {
|
||||
hash, err := blurhash.Encode(gradientImage(w, h))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9))
|
||||
},
|
||||
Entry("square album art", 60, 60, 5, 5),
|
||||
Entry("smallest square", 1, 1, 5, 5),
|
||||
Entry("landscape 16:9", 192, 108, 6, 4),
|
||||
Entry("portrait 9:16", 108, 192, 4, 6),
|
||||
Entry("extreme landscape capped at 9", 1000, 10, 9, 1),
|
||||
Entry("extreme portrait capped at 9", 10, 1000, 1, 9),
|
||||
)
|
||||
|
||||
It("rejects an empty image", func() {
|
||||
_, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("produces the spec-mandated length", func() {
|
||||
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5
|
||||
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1)))
|
||||
})
|
||||
|
||||
It("stores the average color in the DC component", func() {
|
||||
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
dc := decode83(h[2:6])
|
||||
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
|
||||
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
|
||||
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := gradientImage(64, 64)
|
||||
h1, err1 := blurhash.Encode(img)
|
||||
h2, err2 := blurhash.Encode(img)
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(h1).To(Equal(h2))
|
||||
})
|
||||
|
||||
It("produces different hashes for different images", func() {
|
||||
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}))
|
||||
h2, _ := blurhash.Encode(gradientImage(16, 16))
|
||||
Expect(h1).ToNot(Equal(h2))
|
||||
})
|
||||
|
||||
It("downscales large images internally without changing the result materially", func() {
|
||||
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(big[2:6]).To(Equal(small[2:6]))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
PreCache(artID model.ArtworkID)
|
||||
}
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
coverArtSize: conf.Server.UICoverArtSize,
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true})
|
||||
go a.run(ctx)
|
||||
return a
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
coverArtSize int
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
a.sendWakeSignal()
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case a.wakeSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) run(ctx context.Context) {
|
||||
for {
|
||||
a.waitSignal(ctx, 10*time.Second)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
a.mutex.Lock()
|
||||
bufferLen := len(a.buffer)
|
||||
a.mutex.Unlock()
|
||||
if bufferLen > 0 {
|
||||
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
a.mutex.Lock()
|
||||
|
||||
// If there's nothing to send, keep waiting
|
||||
if len(a.buffer) == 0 {
|
||||
a.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
batch := slices.Collect(maps.Keys(a.buffer))
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
a.processBatch(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
case <-a.wakeSignal:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
size := a.coverArtSize
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
type noopCacheWarmer struct{}
|
||||
|
||||
func (a *noopCacheWarmer) PreCache(model.ArtworkID) {}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := range 5 {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("pre-caches UICoverArtSize", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
mu sync.Mutex
|
||||
cachedSizes []int
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cachedSizes = append(m.cachedSizes, size)
|
||||
m.mu.Unlock()
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) getCachedSizes() []int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]int, len(m.cachedSizes))
|
||||
copy(result, m.cachedSizes)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetReady(v bool) {
|
||||
f.ready.Store(v)
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
|
|
@ -12,16 +11,16 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// discArtworkReader resolves disc-level artwork from a library's folder images
|
||||
// and embedded tags. It is used by the serving path's provisional disc read-through.
|
||||
type discArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
album model.Album
|
||||
discNumber int
|
||||
imgFiles []string // library-relative, forward-slash, no leading slash
|
||||
|
|
@ -29,27 +28,40 @@ type discArtworkReader struct {
|
|||
isMultiFolder bool
|
||||
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
|
||||
lib libraryView
|
||||
updatedAt *time.Time
|
||||
// Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be
|
||||
// replaced without the album row changing, so this is what makes a cache key notice it.
|
||||
imagesUpdatedAt time.Time
|
||||
}
|
||||
|
||||
func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) {
|
||||
// cacheTime is the disc image's validity stamp: any of these moving means the selection may
|
||||
// have changed.
|
||||
func (d *discArtworkReader) cacheTime() time.Time {
|
||||
return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt)
|
||||
}
|
||||
|
||||
func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := a.ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al)
|
||||
_, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imagesUpdatedAt time.Time
|
||||
if albumImagesAt != nil {
|
||||
imagesUpdatedAt = *albumImagesAt
|
||||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
|
|
@ -58,29 +70,25 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
|
|||
return nil, err
|
||||
}
|
||||
|
||||
lib, err := loadLibraryView(ctx, a.ds, al.LibraryID)
|
||||
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build disc folder set and find first track. mf.Path is already library-relative.
|
||||
var firstTrackRel string
|
||||
allFolderIDs := make(map[string]bool)
|
||||
for _, mf := range mfs {
|
||||
allFolderIDs[mf.FolderID] = true
|
||||
if firstTrackRel == "" {
|
||||
if mf.Path != "" {
|
||||
firstTrackRel = filepath.ToSlash(mf.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID }))
|
||||
|
||||
// Resolve folder IDs to library-relative paths
|
||||
discFoldersRel := make(map[string]bool)
|
||||
if len(allFolderIDs) > 0 {
|
||||
folderIDs := make([]string, 0, len(allFolderIDs))
|
||||
for id := range allFolderIDs {
|
||||
folderIDs = append(folderIDs, id)
|
||||
}
|
||||
folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -89,49 +97,20 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
|
|||
for _, f := range folders {
|
||||
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
|
||||
discFoldersRel[rel] = true
|
||||
imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
isMultiFolder := len(al.FolderIDs) > 1
|
||||
|
||||
r := &discArtworkReader{
|
||||
a: a,
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFoldersRel: discFoldersRel,
|
||||
isMultiFolder: isMultiFolder,
|
||||
firstTrackRel: firstTrackRel,
|
||||
lib: lib,
|
||||
updatedAt: imagesUpdatedAt,
|
||||
}
|
||||
r.cacheKey.artID = artID
|
||||
r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
|
||||
if imagesUpdatedAt != nil {
|
||||
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x",
|
||||
d.cacheKey.Key(),
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||
return d.lastUpdate
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority)
|
||||
// Fallback to album cover art
|
||||
albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt)
|
||||
ff = append(ff, fromAlbum(ctx, d.a, albumArtID))
|
||||
return selectImageReader(ctx, d.cacheKey.artID, ff...)
|
||||
return &discArtworkReader{
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFoldersRel: discFoldersRel,
|
||||
isMultiFolder: len(al.FolderIDs) > 1,
|
||||
firstTrackRel: firstTrackRel,
|
||||
lib: lib,
|
||||
imagesUpdatedAt: imagesUpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
|
|
@ -162,14 +141,13 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
|
|||
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
name := path.Base(file)
|
||||
stem := strings.TrimSuffix(name, path.Ext(name))
|
||||
stem := utils.BaseName(file)
|
||||
if !strings.EqualFold(stem, subtitle) {
|
||||
continue
|
||||
}
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
|
|
@ -178,19 +156,12 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
|
|||
}
|
||||
}
|
||||
|
||||
// globMetaChars holds the substitution metacharacters understood by
|
||||
// filepath.Match. The '\' escape character is intentionally excluded:
|
||||
// disc art patterns come from user config and never include escaped
|
||||
// metachars in practice, and treating '\' as a metachar would misalign
|
||||
// the literal-prefix extraction in extractDiscNumber.
|
||||
// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar
|
||||
// would misalign the literal-prefix extraction in extractDiscNumber.
|
||||
const globMetaChars = "*?["
|
||||
|
||||
// extractDiscNumber parses the disc number from a filename matched by a
|
||||
// filepath.Match-style glob pattern.
|
||||
//
|
||||
// Both pattern and filename must already be lowercased by the caller, which
|
||||
// is also expected to have verified that filepath.Match(pattern, filename)
|
||||
// is true before calling this function.
|
||||
// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style
|
||||
// glob. Caller must lowercase both args and have already verified the match.
|
||||
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
metaIdx := strings.IndexAny(pattern, globMetaChars)
|
||||
if metaIdx < 0 {
|
||||
|
|
@ -216,9 +187,8 @@ func extractDiscNumber(pattern, filename string) (int, bool) {
|
|||
return num, true
|
||||
}
|
||||
|
||||
// fromExternalFile returns a sourceFunc that matches image files against a glob
|
||||
// pattern. A numbered filename whose number equals the target disc wins over
|
||||
// any unnumbered candidate; callers must pass a lowercase pattern.
|
||||
// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered
|
||||
// filename whose number equals the target disc wins over any unnumbered candidate.
|
||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
|
|
@ -227,7 +197,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
|||
name := strings.ToLower(path.Base(file))
|
||||
match, err := filepath.Match(pattern, name)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
|
|
@ -241,7 +211,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
|||
}
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
|
|
@ -257,7 +227,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
|||
for _, file := range fallbacks {
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
127
core/artwork/dominant/dominant.go
Normal file
127
core/artwork/dominant/dominant.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the
|
||||
// real artwork loads.
|
||||
package dominant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
// 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that
|
||||
// distinct colours stay apart.
|
||||
bits = 4
|
||||
nBins = 1 << (3 * bits)
|
||||
// Only the heaviest bins can win, and merging is O(n^2) over whatever survives.
|
||||
maxBins = 64
|
||||
// Oklab distance below which two bins are the same colour to the eye. Merging matters because a
|
||||
// gradient splits across adjacent bins and would otherwise lose to a smaller flat region.
|
||||
mergeDist = 0.10
|
||||
)
|
||||
|
||||
type bin struct {
|
||||
r, g, b float64
|
||||
n float64
|
||||
}
|
||||
|
||||
// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports
|
||||
// presence, not salience: a mostly white sleeve returns white.
|
||||
func Color(img image.Image) string {
|
||||
var bins [nBins]bin
|
||||
total := 0
|
||||
eachPixel(img, func(r, g, b uint8) {
|
||||
i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<<bits | int(b>>(8-bits))
|
||||
bins[i].r += float64(r)
|
||||
bins[i].g += float64(g)
|
||||
bins[i].b += float64(b)
|
||||
bins[i].n++
|
||||
total++
|
||||
})
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
used := make([]bin, 0, 32)
|
||||
for i := range bins {
|
||||
if bins[i].n > 0 {
|
||||
used = append(used, bins[i])
|
||||
}
|
||||
}
|
||||
sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n })
|
||||
if len(used) > maxBins {
|
||||
used = used[:maxBins]
|
||||
}
|
||||
|
||||
merged := make([]bin, 0, len(used))
|
||||
for _, b := range used {
|
||||
if i := nearest(merged, b); i >= 0 {
|
||||
merged[i].r += b.r
|
||||
merged[i].g += b.g
|
||||
merged[i].b += b.b
|
||||
merged[i].n += b.n
|
||||
continue
|
||||
}
|
||||
merged = append(merged, b)
|
||||
}
|
||||
|
||||
best := merged[0]
|
||||
for _, m := range merged[1:] {
|
||||
if m.n > best.n {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x",
|
||||
uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5))
|
||||
}
|
||||
|
||||
func nearest(merged []bin, b bin) int {
|
||||
bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n)
|
||||
for i, m := range merged {
|
||||
ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n)
|
||||
if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash
|
||||
// encoders already read the shared thumbnail in that form.
|
||||
func eachPixel(img image.Image, fn func(r, g, b uint8)) {
|
||||
if p, ok := img.(*image.NRGBA); ok {
|
||||
for y := range p.Rect.Dy() {
|
||||
row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4]
|
||||
for x := 0; x < len(row); x += 4 {
|
||||
fn(row[x], row[x+1], row[x+2])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, _ := img.At(x, y).RGBA()
|
||||
fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func srgbToLinear(v float64) float64 {
|
||||
v /= 255
|
||||
if v <= 0.04045 {
|
||||
return v / 12.92
|
||||
}
|
||||
return math.Pow((v+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func oklab(r, g, b float64) (float64, float64, float64) {
|
||||
lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)
|
||||
l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb)
|
||||
m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb)
|
||||
s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb)
|
||||
return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
|
||||
1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
|
||||
0.0259040371*l + 0.7827717662*m - 0.8086757660*s
|
||||
}
|
||||
17
core/artwork/dominant/dominant_suite_test.go
Normal file
17
core/artwork/dominant/dominant_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package dominant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDominant(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Dominant Suite")
|
||||
}
|
||||
91
core/artwork/dominant/dominant_test.go
Normal file
91
core/artwork/dominant/dominant_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package dominant_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/dominant"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fill paints rect with c onto img.
|
||||
func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) {
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newImg(w, h int, c color.NRGBA) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
fill(img, img.Bounds(), c)
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Color", func() {
|
||||
It("returns a solid image's own colour", func() {
|
||||
Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699"))
|
||||
})
|
||||
|
||||
It("returns empty for an image with no pixels", func() {
|
||||
Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal(""))
|
||||
})
|
||||
|
||||
// Presence, not salience: this is a placeholder, so the large field wins even though the small
|
||||
// patch is the more interesting colour.
|
||||
It("picks the largest area, not the most vivid one", func() {
|
||||
img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255})
|
||||
fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255})
|
||||
Expect(dominant.Color(img)).To(Equal("#fafafa"))
|
||||
})
|
||||
|
||||
It("reports a near-black cover as near-black", func() {
|
||||
img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255})
|
||||
fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255})
|
||||
Expect(dominant.Color(img)).To(Equal("#050505"))
|
||||
})
|
||||
|
||||
// A gradient splits across many quantisation bins. Without merging, each slice is smaller than
|
||||
// the flat block and the block would win despite covering far less of the image.
|
||||
It("merges a gradient's bins so it beats a smaller flat block", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 40, 40))
|
||||
for y := range 40 {
|
||||
for x := range 40 {
|
||||
// 30 columns of blue gradient == 75% of the image
|
||||
if x < 30 {
|
||||
img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255})
|
||||
} else {
|
||||
img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
got := dominant.Color(img)
|
||||
Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got)
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 30, 30))
|
||||
for y := range 30 {
|
||||
for x := range 30 {
|
||||
img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255})
|
||||
}
|
||||
}
|
||||
first := dominant.Color(img)
|
||||
for range 5 {
|
||||
Expect(dominant.Color(img)).To(Equal(first))
|
||||
}
|
||||
})
|
||||
|
||||
It("handles images that are not NRGBA", func() {
|
||||
src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255})
|
||||
rgba := image.NewRGBA(src.Bounds())
|
||||
for y := range 10 {
|
||||
for x := range 10 {
|
||||
rgba.Set(x, y, src.At(x, y))
|
||||
}
|
||||
}
|
||||
Expect(dominant.Color(rgba)).To(Equal("#204060"))
|
||||
})
|
||||
})
|
||||
326
core/artwork/e2e/acquire_serve_test.go
Normal file
326
core/artwork/e2e/acquire_serve_test.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites.
|
||||
var _ = Describe("Acquisition → serve loop", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
artistRepo *tests.MockArtistRepo
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
plRepo *tests.MockPlaylistRepo
|
||||
radioRepo *tests.MockedRadioRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
store *artwork.ImageStore
|
||||
svc artwork.Artwork
|
||||
worker *artwork.Worker
|
||||
coverBytes []byte
|
||||
)
|
||||
|
||||
itemFound := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
}
|
||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash == ""
|
||||
}
|
||||
}
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
bump := func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
coverBytes = readFixture(coverFixture)
|
||||
|
||||
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
conf.Server.DevArtworkWorkerConcurrency = 1
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}})
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
artistRepo = tests.CreateMockArtistRepo()
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
plRepo = tests.CreateMockPlaylistRepo()
|
||||
radioRepo = tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedPlaylist: plRepo,
|
||||
MockedRadio: radioRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
store = artwork.NewImageStore(GinkgoT().TempDir())
|
||||
// size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
|
||||
imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0,
|
||||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, errors.New("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
|
||||
|
||||
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
|
||||
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
})
|
||||
|
||||
seedFolderAlbum := func(albumID string) {
|
||||
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
}
|
||||
|
||||
It("acquires album folder art and serves the exact bytes under its hash", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("acquires an artist's uploaded image and serves it", func() {
|
||||
name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture)
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}})
|
||||
bump("ar", "ar1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
|
||||
})
|
||||
|
||||
It("generates a playlist grid from its tracks' album art and serves it from the store", func() {
|
||||
seedFolderAlbum("al1")
|
||||
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}}
|
||||
bump("pl", "pl1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/png"))
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("acquires a radio station's uploaded image and serves it", func() {
|
||||
name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture,
|
||||
}})
|
||||
|
||||
provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(provisional.Placeholder).To(BeFalse())
|
||||
Expect(provisional.Hash).ToNot(BeEmpty())
|
||||
provisionalBytes := readAll(provisional)
|
||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||
|
||||
// The provisional read enqueued a Bump; drain it.
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||
|
||||
resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resolved.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(resolved)).To(Equal(provisionalBytes))
|
||||
})
|
||||
|
||||
It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||
Expect(art.Width).To(BeNumerically(">", 0))
|
||||
Expect(art.Height).To(BeNumerically(">", 0))
|
||||
Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes)))
|
||||
// Never a synthesized value: both hashes are encoded from the real pixels.
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
Expect(art.ThumbHash).ToNot(BeEmpty())
|
||||
raw, err := base64.StdEncoding.DecodeString(art.ThumbHash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(raw)).To(BeNumerically(">=", 5))
|
||||
})
|
||||
|
||||
It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/gif"))
|
||||
Expect(art.Width).To(BeNumerically("==", 4))
|
||||
})
|
||||
|
||||
It("deduplicates byte-identical art across entities onto one image row", func() {
|
||||
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0},
|
||||
{ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0},
|
||||
})
|
||||
bump("al", "al1")
|
||||
bump("al", "al2")
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||
})
|
||||
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("stops serving a file-backed image once its source file changes underneath", func() {
|
||||
name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
staleHash := ia.Hash
|
||||
|
||||
path := model.UploadedImagePath(consts.EntityRadio, name)
|
||||
Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed())
|
||||
newer := time.Now().Add(2 * time.Second)
|
||||
Expect(os.Chtimes(path, newer, newer)).To(Succeed())
|
||||
|
||||
// The mtime no longer matches the state row, so the stale bytes are not served.
|
||||
_, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
// That failed read enqueued a re-resolution.
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||
})
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
|
||||
})
|
||||
|
||||
It("records an absent state for an entity with no art and reports it unavailable", func() {
|
||||
albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}})
|
||||
bump("al", "alx")
|
||||
runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx"))
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
func mustGet(img *artwork.Image, err error) *artwork.Image {
|
||||
GinkgoHelper()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return img
|
||||
}
|
||||
|
||||
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
|
||||
// the production import the spec above guards.
|
||||
var gifFixture = []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,
|
||||
0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05,
|
||||
0x00, 0x3b,
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
|
@ -9,14 +9,11 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so
|
||||
// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes.
|
||||
var _ = Describe("Album artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("an album has a single folder with cover.jpg at the album root", func() {
|
||||
|
|
@ -28,12 +25,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -55,16 +50,16 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
|
||||
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(al.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
"sanity check: the two disc subfolders should form one multi-disc album")
|
||||
expectAlbumFolderCover(al, "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -86,14 +81,12 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/folder.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
"Artist/Album/folder.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -109,12 +102,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -133,14 +124,12 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Album/cover.jpg": imageFile("album-root"),
|
||||
"Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
"Album/cover.jpg": smallPNG("album-root"),
|
||||
"Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -153,14 +142,14 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
"Artist/Album/cover.jpg": imageFile("external"),
|
||||
"Artist/Album/cover.jpg": smallPNG("external"),
|
||||
})
|
||||
scan()
|
||||
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -176,8 +165,9 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -190,12 +180,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
|
||||
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -209,30 +197,25 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("secondary"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("secondary"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
|
||||
It("returns ErrUnavailable", func() {
|
||||
// └── 01 - Track.mp3 (no image files — settles absent)
|
||||
It("settles absent", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
|
||||
Expect(err).To(HaveOccurred())
|
||||
expectAlbumAbsent(firstAlbum())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -248,12 +231,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -266,12 +247,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -286,14 +265,12 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -307,13 +284,11 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -328,14 +303,12 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.2.jpg": imageFile("second"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("first"),
|
||||
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("first"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -348,12 +321,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -371,21 +342,16 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
It("does not use the artist image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"Album A has no images of its own, so it must fall through to the placeholder "+
|
||||
"instead of inheriting the artist folder's folder.jpg")
|
||||
|
||||
alB := albumByName("Album B")
|
||||
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
|
||||
// Album B first: the acquire in expectAlbumAbsent would settle Album B too.
|
||||
expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg")
|
||||
expectAlbumAbsent(albumByName("Album A"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -402,21 +368,58 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
It("does not use the artist image as album art for the spread album", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"the spread album has no images of its own, so it must fall through to the "+
|
||||
"placeholder instead of inheriting the artist folder's folder.jpg")
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectAlbumAbsent(alA)
|
||||
})
|
||||
})
|
||||
|
||||
// albumRootParent refuses the library root as an album root (parent.ParentID == "").
|
||||
When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() {
|
||||
// (library root)
|
||||
// ├── cover.jpg ← must NOT be adopted
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── CD2/
|
||||
// └── 01 - Track.mp3
|
||||
It("does not adopt the library-root image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"cover.jpg": smallPNG("library-root"),
|
||||
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
||||
"CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}),
|
||||
})
|
||||
scan()
|
||||
expectAlbumAbsent(firstAlbum())
|
||||
})
|
||||
})
|
||||
|
||||
// The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips
|
||||
// the parent folder for a single-folder album that has images of its own.
|
||||
When("a single-folder album has its own cover.jpg and the artist folder has one too", func() {
|
||||
// Artist/
|
||||
// ├── cover.jpg ← shallower, but must NOT win
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← should win
|
||||
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-own"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -430,13 +433,13 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A")
|
||||
// └── Album B/
|
||||
// └── 01 - Track.mp3
|
||||
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
||||
It("prefers the album's own art over the artist image", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": imageFile("artist-image"),
|
||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A/front.jpg": imageFile("album-a-front"),
|
||||
"Artist/Album A/front.jpg": smallPNG("album-a-front"),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
})
|
||||
|
|
@ -444,8 +447,8 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectAlbumFolderCover(alA, "Artist/Album A/front.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -458,12 +461,10 @@ var _ = Describe("Album artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -16,9 +17,11 @@ import (
|
|||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#artists
|
||||
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
|
||||
// Library-folder images are file-backed (asserted on the worker state row); uploaded and
|
||||
// image-folder images are real files on disk (asserted byte-for-byte).
|
||||
var _ = Describe("Artist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("the artist folder contains an artist.jpg", func() {
|
||||
|
|
@ -30,13 +33,10 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -49,13 +49,10 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -69,14 +66,11 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -89,13 +83,10 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "artist.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -111,14 +102,11 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -136,14 +124,11 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -164,14 +149,11 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -189,18 +171,19 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
ar := soleArtist()
|
||||
|
||||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
|
||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -213,13 +196,36 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
conf.Server.ArtistArtPriority = "album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// resolveArtist only samples albums where this artist is the SOLE album artist, so a
|
||||
// collaboration or compilation never donates its images as the artist's own.
|
||||
When("the artist's only album is credited to two album artists", func() {
|
||||
// Artist/
|
||||
// └── Collab Album/ (album artists: "Artist" + a collaborator)
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← must NOT become the artist image
|
||||
It("ignores the album's images and settles absent", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||
// " / " is a default artists split separator, so this single tag yields two album artists.
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}),
|
||||
"Artist/Collab Album/artist.jpg": smallPNG("collab-artist"),
|
||||
})
|
||||
scan()
|
||||
Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2),
|
||||
"sanity check: the album must be credited to two album artists")
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).
|
||||
To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -232,7 +238,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
// └── 01 - Track.mp3 (no artist.* present in library)
|
||||
It("returns the image from the configured artist image folder", func() {
|
||||
imgFolder := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed())
|
||||
conf.Server.ArtistImageFolder = imgFolder
|
||||
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
||||
|
||||
|
|
@ -242,15 +248,16 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images
|
||||
// are asserted byte-for-byte, while album-root covers are asserted on the state row.
|
||||
var _ = Describe("Disc artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("the album is single-disc with a disc1.jpg in the only folder", func() {
|
||||
|
|
@ -24,32 +26,25 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
|
||||
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-image")
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no per-disc image and no album cover", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
|
||||
It("returns ErrUnavailable for the disc lookup", func() {
|
||||
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
|
||||
It("reports the disc lookup as unavailable", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
_, err := readArtworkOrErr(discID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -63,13 +58,10 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
|
||||
expectDiscImage(firstAlbum(), 1, "album-cover")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -83,14 +75,11 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc-one"),
|
||||
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
|
||||
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
||||
"Artist/Album/disc10.jpg": smallPNG("disc-ten"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
|
||||
expectDiscImage(firstAlbum(), 1, "disc-one")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -108,14 +97,11 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
|
||||
expectDiscImage(firstAlbum(), 2, "disc-2")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -136,14 +122,11 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
|
||||
expectDiscImage(firstAlbum(), 2, "cd-2")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -161,14 +144,11 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-cover")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -188,17 +168,15 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
for _, n := range []int{1, 2} {
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
|
||||
"disc %d should use the album cover when DiscArtPriority is empty", n)
|
||||
expectDiscImage(al, n, "album-cover")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -223,17 +201,15 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/disc2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
|
||||
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
|
||||
expectDiscImage(al, 1, "disc-1")
|
||||
expectDiscImage(al, 2, "cd-2")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -246,13 +222,10 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.DiscArtPriority = "discsubtitle"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
|
||||
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
|
||||
expectDiscImage(firstAlbum(), 1, "bonus-tracks")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -286,26 +259,22 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
|
||||
}
|
||||
layout := fstest.MapFS{
|
||||
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
|
||||
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"),
|
||||
}
|
||||
for i, name := range discNames {
|
||||
discNum := i + 1
|
||||
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg")
|
||||
for i := range discNames {
|
||||
discNum := i + 1
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
|
||||
"disc %d should use its own folder.jpg", discNum)
|
||||
expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -328,24 +297,20 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
layout := fstest.MapFS{
|
||||
"Album/cover.jpg": imageFile("album-root-cover"),
|
||||
"Album/cover.jpg": smallPNG("album-root-cover"),
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
prefix := fmt.Sprintf("Album/Disc %02d/", i)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
|
||||
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
expectAlbumFolderCover(al, "Album/cover.jpg")
|
||||
for i := 1; i <= 3; i++ {
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
|
||||
"disc %d should use its own folder.jpg", i)
|
||||
expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -359,13 +324,10 @@ var _ = Describe("Disc artwork resolution", func() {
|
|||
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
|
||||
expectDiscImage(firstAlbum(), 1, "cover")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
83
core/artwork/e2e/e2e_suite_test.go
Normal file
83
core/artwork/e2e/e2e_suite_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the
|
||||
// real Service serves the result, over a real ImageStore and real library files.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
_ "github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestArtworkE2E(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork Pipeline E2E Suite")
|
||||
}
|
||||
|
||||
// Fixtures relative to the project root (tests.Init chdirs there).
|
||||
const (
|
||||
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
||||
mp3Fixture = "tests/fixtures/artist/an-album/test.mp3"
|
||||
artistPngFixture = "tests/fixtures/artist/an-album/artist.png"
|
||||
albumFolderPath = "tests/fixtures/artist/an-album"
|
||||
)
|
||||
|
||||
func readFixture(rel string) []byte {
|
||||
GinkgoHelper()
|
||||
data, err := os.ReadFile(rel)
|
||||
Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel)
|
||||
return data
|
||||
}
|
||||
|
||||
func readAll(img *artwork.Image) []byte {
|
||||
GinkgoHelper()
|
||||
Expect(img).ToNot(BeNil())
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) {
|
||||
GinkgoHelper()
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||
|
||||
func writeUpload(entityType, name, srcFixture string) string {
|
||||
GinkgoHelper()
|
||||
dst := model.UploadedImagePath(entityType, name)
|
||||
Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed())
|
||||
return name
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
|
||||
// contains a valid MP3 stream with an embedded picture. Used in the
|
||||
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
|
||||
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
|
||||
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
|
||||
// artwork reader gets real bytes when it calls libFS.Open.
|
||||
//
|
||||
//go:embed testdata/embedded_art.mp3
|
||||
var realMP3WithEmbeddedArt []byte
|
||||
|
||||
// embeddedArtBytes is the exact image payload that the artwork reader will
|
||||
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
|
||||
// assert byte-for-byte equality — if this ever differs it means the reader
|
||||
// pulled from a different source.
|
||||
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
|
||||
|
||||
func extractEmbeddedArt(mp3 []byte) []byte {
|
||||
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
|
||||
if err != nil {
|
||||
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
|
||||
}
|
||||
defer tf.Close()
|
||||
images := tf.Properties().Images
|
||||
if len(images) == 0 {
|
||||
panic("embedded-art fixture has no embedded images")
|
||||
}
|
||||
data, err := tf.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
panic("embedded-art fixture: could not read image 0")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
|
||||
// path so libFS.Open returns an MP3 stream taglib can parse.
|
||||
func replaceWithRealMP3(relPath string) {
|
||||
GinkgoHelper()
|
||||
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
|
||||
}
|
||||
|
||||
// placeholderBytes returns the bundled album-placeholder image bytes — the
|
||||
// same stream the artwork reader emits when every source falls through.
|
||||
func placeholderBytes() []byte {
|
||||
GinkgoHelper()
|
||||
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
|
||||
// the given bytes, matching the on-disk layout expected by
|
||||
// model.UploadedImagePath.
|
||||
func writeUploadedImage(entity, filename string, data []byte) {
|
||||
GinkgoHelper()
|
||||
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
func newNoopFFmpeg() *tests.MockFFmpeg {
|
||||
ff := tests.NewMockFFmpeg("")
|
||||
ff.Error = errors.New("noop")
|
||||
return ff
|
||||
}
|
||||
|
||||
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
|
||||
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
|
||||
tags := storagetest.Track(num, title)
|
||||
for _, e := range extra {
|
||||
maps.Copy(tags, e)
|
||||
}
|
||||
return storagetest.MP3(tags)
|
||||
}
|
||||
|
||||
// imageFile builds a label-keyed image entry. The bytes are deterministic
|
||||
// per-label so tests can assert which file won.
|
||||
func imageFile(label string) *fstest.MapFile {
|
||||
return &fstest.MapFile{Data: []byte("image:" + label)}
|
||||
}
|
||||
|
||||
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
|
||||
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
|
||||
func realPNG(label string) *fstest.MapFile {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
// Derive a deterministic color per label.
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(label))
|
||||
sum := h.Sum32()
|
||||
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
|
||||
for y := range 2 {
|
||||
for x := range 2 {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||
return &fstest.MapFile{Data: buf.Bytes()}
|
||||
}
|
||||
|
||||
// imageBytes returns the bytes that imageFile(label) writes.
|
||||
func imageBytes(label string) []byte { return imageFile(label).Data }
|
||||
|
||||
// setLayout populates fakeFS with the given map. Call after setupHarness.
|
||||
// All paths must be forward-slash and relative (no leading "/").
|
||||
func setLayout(files fstest.MapFS) {
|
||||
GinkgoHelper()
|
||||
fakeFS.SetFiles(files)
|
||||
}
|
||||
|
||||
func readArtwork(artID model.ArtworkID) []byte {
|
||||
GinkgoHelper()
|
||||
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return b
|
||||
}
|
||||
|
||||
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
|
||||
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
// noopProvider implements external.Provider with not-found returns so the
|
||||
// "external" priority entry never produces a result.
|
||||
type noopProvider struct{}
|
||||
|
||||
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
var _ external.Provider = (*noopProvider)(nil)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
|
@ -17,13 +17,11 @@ import (
|
|||
// 2. For multi-disc albums, disc-level artwork
|
||||
// 3. Album cover art
|
||||
//
|
||||
// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1)
|
||||
// is covered by the existing embedded-art album tests (which currently
|
||||
// Skip under FakeFS). The tests below cover (2) and (3): the fallback
|
||||
// chain for tracks without embedded art.
|
||||
var _ = Describe("MediaFile artwork fallback", func() {
|
||||
// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a
|
||||
// serve-time read through the library FS.
|
||||
var _ = Describe("MediaFile artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art", func() {
|
||||
|
|
@ -42,14 +40,14 @@ var _ = Describe("MediaFile artwork fallback", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -63,12 +61,12 @@ var _ = Describe("MediaFile artwork fallback", func() {
|
|||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -86,19 +84,60 @@ var _ = Describe("MediaFile artwork fallback", func() {
|
|||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a track has its own embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 ← has embedded picture (wins over every fallback)
|
||||
It("resolves the track's embedded image into the store", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
ia := acquire(model.KindMediaFileArtwork, mf.ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("EnableMediaFileCoverArt is turned off after the track was scanned", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← has embedded picture (must NOT be served)
|
||||
// └── cover.jpg ← wins (per-track art disabled at serve time)
|
||||
It("serves the album cover instead of the track's embedded art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
// The setting is not part of the artwork fingerprint, so it must be honored at serve time.
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil)
|
||||
Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -17,14 +20,13 @@ import (
|
|||
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
|
||||
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
|
||||
// 4. Generated 2x2 tiled cover from the playlist's albums
|
||||
// 5. Album placeholder image
|
||||
// 5. Absent
|
||||
//
|
||||
// The library FS is FakeFS, but uploaded/sidecar/local-external images are
|
||||
// real files on disk — the reader reads them via os.Open, so the tests
|
||||
// place them in a real tempdir under DataFolder.
|
||||
// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on
|
||||
// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir.
|
||||
var _ = Describe("Playlist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a playlist has an uploaded image", func() {
|
||||
|
|
@ -33,11 +35,12 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
// └── playlist/
|
||||
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
|
||||
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload"))
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -48,12 +51,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
It("returns the sidecar image", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -64,12 +69,12 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
It("matches case-insensitively", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png")))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -80,31 +85,39 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local")))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
|
||||
// (no local files — http source is gated off, reader falls through to placeholder)
|
||||
It("skips the URL and falls through to the bundled placeholder", func() {
|
||||
// (no local files — the http source is gated off, so resolution settles absent)
|
||||
It("skips the URL and settles absent", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no images and no tracks", func() {
|
||||
// (reader falls all the way through to the bundled album placeholder)
|
||||
It("returns the album placeholder", func() {
|
||||
// (no uploaded/sidecar/external image and no album art to sample)
|
||||
It("settles absent", func() {
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -113,37 +126,79 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
// Artist/
|
||||
// ├── AlbumA/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.png (real PNG — wins as tile 1 source)
|
||||
// │ └── cover.png ← tile 1 source
|
||||
// └── AlbumB/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.png (real PNG — wins as tile 2 source)
|
||||
// Playlist "pl-7" references tracks from both albums, so the reader
|
||||
// generates a 2x2 tiled cover from 2 distinct album art tiles (the
|
||||
// tiled generator mirrors when it has fewer than 4 unique tiles).
|
||||
// └── cover.png ← tile 2 source
|
||||
// Playlist "pl-7" references tracks from both albums, so the worker generates a tiled
|
||||
// cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid).
|
||||
It("generates a tiled cover from album art", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
|
||||
"Artist/AlbumA/cover.png": realPNG("albumA"),
|
||||
"Artist/AlbumA/cover.png": smallPNG("albumA"),
|
||||
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
|
||||
"Artist/AlbumB/cover.png": realPNG("albumB"),
|
||||
"Artist/AlbumB/cover.png": smallPNG("albumB"),
|
||||
})
|
||||
scan()
|
||||
|
||||
// Pull the scanned mediafile IDs so we can attach them to the playlist.
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
|
||||
data := readArtwork(pl.CoverArtID())
|
||||
// The tiled cover is a PNG-encoded 600x600 image (tileSize const).
|
||||
// Exact bytes vary (random album order), so assert format + non-trivial size.
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
data := storedBytes(ia)
|
||||
// The tiled cover is a PNG-encoded image; exact bytes vary (random album order).
|
||||
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
|
||||
Expect(len(data)).To(BeNumerically(">", 1000))
|
||||
// Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match.
|
||||
q := gridQuadrants(data)
|
||||
Expect(q[0]).To(Equal(q[3]))
|
||||
Expect(q[1]).To(Equal(q[2]))
|
||||
Expect(q[0]).ToNot(Equal(q[1]))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has tracks from four albums, each with its own cover", func() {
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1
|
||||
// ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2
|
||||
// ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3
|
||||
// └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4
|
||||
It("fills all four grid quadrants with distinct album art", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
layout := fstest.MapFS{}
|
||||
for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} {
|
||||
layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name})
|
||||
layout["Artist/"+name+"/cover.png"] = smallPNG(name)
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(4))
|
||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
|
||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID(ids)
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
q := gridQuadrants(storedBytes(ia))
|
||||
Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4))
|
||||
Expect(q[0]).ToNot(Equal(q[1]))
|
||||
Expect(q[0]).ToNot(Equal(q[2]))
|
||||
Expect(q[0]).ToNot(Equal(q[3]))
|
||||
Expect(q[1]).ToNot(Equal(q[2]))
|
||||
Expect(q[1]).ToNot(Equal(q[3]))
|
||||
Expect(q[2]).ToNot(Equal(q[3]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -153,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
|||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
package artworke2e_test
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they
|
||||
// serve back byte-for-byte; a radio with no upload settles absent.
|
||||
var _ = Describe("Radio artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a radio has an uploaded image", func() {
|
||||
|
|
@ -18,25 +21,25 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
// └── radio/
|
||||
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
|
||||
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo")))
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a radio has no uploaded image", func() {
|
||||
// (no files on disk — reader has no sources to fall back to)
|
||||
It("returns ErrUnavailable", func() {
|
||||
// (no files on disk — the resolver has no sources to fall back to)
|
||||
It("settles absent", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
_, err := readArtworkOrErr(artID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
365
core/artwork/e2e/resolution_harness_test.go
Normal file
365
core/artwork/e2e/resolution_harness_test.go
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/tests/harness"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
var (
|
||||
rctx context.Context
|
||||
rds *tests.MockDataStore
|
||||
rstore *artwork.ImageStore
|
||||
rsvc artwork.Artwork
|
||||
rworker *artwork.Worker
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a
|
||||
// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir.
|
||||
var suiteDBTempDir string
|
||||
|
||||
// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating.
|
||||
var userTables []string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true}))
|
||||
|
||||
userTables = harness.ResettableTables()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(context.Background())
|
||||
})
|
||||
|
||||
func setupResolutionHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
conf.Server.EnableExternalServices = false
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
conf.Server.DevArtworkWorkerConcurrency = 1
|
||||
|
||||
rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
harness.TruncateDB(userTables)
|
||||
|
||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder))
|
||||
// size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
|
||||
imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0,
|
||||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, fmt.Errorf("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue())
|
||||
|
||||
rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm)
|
||||
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
}
|
||||
|
||||
// setLayout paths must be relative and forward-slash.
|
||||
func setLayout(files fstest.MapFS) {
|
||||
GinkgoHelper()
|
||||
fakeFS.SetFiles(files)
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(rctx, rds, events.NoopBroker(),
|
||||
playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(rctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ia = got
|
||||
return true
|
||||
})
|
||||
return *ia
|
||||
}
|
||||
|
||||
func runResolutionWorkerUntil(until func() bool) {
|
||||
GinkgoHelper()
|
||||
runCtx, cancel := context.WithCancel(rctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- rworker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
||||
func serveBytes(artID model.ArtworkID) []byte {
|
||||
GinkgoHelper()
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
func serveErr(artID model.ArtworkID) error {
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
if img != nil {
|
||||
img.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func libFileBytes(suffix string) []byte {
|
||||
GinkgoHelper()
|
||||
var match string
|
||||
for name := range fakeFS.MapFS {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name)
|
||||
match = name
|
||||
}
|
||||
}
|
||||
Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix)
|
||||
return fakeFS.MapFS[match].Data
|
||||
}
|
||||
|
||||
// Serving before acquiring is deliberate: with no state row the request resolves through the
|
||||
// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve.
|
||||
func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||
GinkgoHelper()
|
||||
requireNoStateRow(model.KindAlbumArtwork, al.ID)
|
||||
Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix)))
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||
func requireNoStateRow(kind model.Kind, id string) {
|
||||
GinkgoHelper()
|
||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||
}
|
||||
|
||||
func expectAlbumAbsent(al model.Album) {
|
||||
GinkgoHelper()
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
}
|
||||
|
||||
func expectArtistFolder(ar model.Artist, suffix string) {
|
||||
GinkgoHelper()
|
||||
requireNoStateRow(model.KindArtistArtwork, ar.ID)
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix)))
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
func writeUploadedImage(entity, filename string, data []byte) {
|
||||
GinkgoHelper()
|
||||
dst := model.UploadedImagePath(entity, filename)
|
||||
Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(dst, data, 0o600)).To(Succeed())
|
||||
}
|
||||
|
||||
func discArtID(al model.Album, disc int) model.ArtworkID {
|
||||
return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt)
|
||||
}
|
||||
|
||||
// Disc art is a pure serve-time read through the library FS: no worker, no state row.
|
||||
func expectDiscImage(al model.Album, disc int, label string) {
|
||||
GinkgoHelper()
|
||||
Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label)))
|
||||
}
|
||||
|
||||
// Samples in rect() order: top-left, top-right, bottom-left, bottom-right.
|
||||
func gridQuadrants(data []byte) [4]color.RGBA {
|
||||
GinkgoHelper()
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b := img.Bounds()
|
||||
qw, qh := b.Dx()/4, b.Dy()/4
|
||||
at := func(x, y int) color.RGBA {
|
||||
c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y))
|
||||
return c.(color.RGBA)
|
||||
}
|
||||
return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)}
|
||||
}
|
||||
|
||||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
// The pixel color derives from label, so each label yields distinct, still-decodable bytes.
|
||||
func smallPNG(label string) *fstest.MapFile {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(label))
|
||||
sum := h.Sum32()
|
||||
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
for y := range 2 {
|
||||
for x := range 2 {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||
return &fstest.MapFile{Data: buf.Bytes()}
|
||||
}
|
||||
|
||||
func pngBytes(label string) []byte {
|
||||
GinkgoHelper()
|
||||
return smallPNG(label).Data
|
||||
}
|
||||
|
||||
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
|
||||
tags := storagetest.Track(num, title)
|
||||
for _, e := range extra {
|
||||
maps.Copy(tags, e)
|
||||
}
|
||||
return storagetest.MP3(tags)
|
||||
}
|
||||
|
||||
// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3
|
||||
// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first.
|
||||
var (
|
||||
embeddedFixtureOnce sync.Once
|
||||
embeddedArtFixture []byte
|
||||
embeddedArtBytes []byte
|
||||
)
|
||||
|
||||
func loadEmbeddedFixture() {
|
||||
embeddedFixtureOnce.Do(func() {
|
||||
embeddedArtFixture = readFixture(mp3Fixture)
|
||||
embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture)
|
||||
})
|
||||
}
|
||||
|
||||
func extractEmbeddedArt(mp3 []byte) []byte {
|
||||
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
|
||||
if err != nil {
|
||||
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
|
||||
}
|
||||
defer tf.Close()
|
||||
images := tf.Properties().Images
|
||||
if len(images) == 0 {
|
||||
panic("embedded-art fixture has no embedded images")
|
||||
}
|
||||
data, err := tf.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
panic("embedded-art fixture: could not read image 0")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func replaceWithRealMP3(relPath string) {
|
||||
GinkgoHelper()
|
||||
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture}
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestArtworkE2E(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork E2E Suite")
|
||||
}
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
aw artwork.Artwork
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps
|
||||
// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup
|
||||
// can't unlink a file with a live handle on Windows. A suite-level tempdir
|
||||
// combined with an AfterSuite close avoids the lock conflict.
|
||||
var suiteDBTempDir string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(GinkgoT().Context())
|
||||
})
|
||||
|
||||
func setupHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
// Reuse the suite-level DB path so the singleton connection keeps working
|
||||
// across specs (see suiteDBTempDir comment).
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
|
||||
conf.Server.EnableExternalServices = false
|
||||
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{})
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
Binary file not shown.
|
|
@ -3,107 +3,19 @@ package artwork
|
|||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/natural"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type albumArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string // library-relative, forward-slash, no leading slash
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
||||
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &albumArtworkReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
lib: lib,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
|
||||
if imagesUpdateAt != nil {
|
||||
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hashInput = conf.Server.Agents + hashInput
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
a.cacheKey.Key(),
|
||||
hash,
|
||||
conf.Server.EnableExternalServices,
|
||||
)
|
||||
}
|
||||
func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
embedRel := a.album.EmbedArtPath
|
||||
ff = append(ff,
|
||||
fromTag(ctx, a.lib.FS, embedRel),
|
||||
fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
|
||||
)
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
||||
case len(a.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) {
|
||||
folders, err := loadFolders(ctx, ds, album.FolderIDs)
|
||||
if err != nil {
|
||||
|
|
@ -169,7 +81,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
|||
}
|
||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
214
core/artwork/folders_album_test.go
Normal file
214
core/artwork/folders_album_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that
|
||||
// the album-root parent is only fetched when it could qualify, and what happens when that fails.
|
||||
var _ = Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
repo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("does not query the parent when it is already one of the album's folders", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not query the parent when the album's folders have different parents", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not query the parent for a single-folder album that has images of its own", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not promote the library root, so its images never become album art", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: ".", Name: "AlbumPart1", ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: ".", Name: "AlbumPart2", ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not promote a parent that holds another album's audio", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}}
|
||||
repo.hasOtherAudio = true
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg"))
|
||||
})
|
||||
|
||||
It("promotes the album root parent into the returned paths", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}}
|
||||
|
||||
paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(paths).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("propagates errors from the album-root check", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}}
|
||||
repo.otherAudioErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from the parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.getErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("continues when the parent folder has been deleted", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
// folderImages is the sort that decides which of several same-named images wins, so it is pinned
|
||||
// directly rather than through a layout that can only show the winner.
|
||||
var _ = Describe("folderImages", func() {
|
||||
It("prefers base filenames over numeric-suffixed ones", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements(
|
||||
"Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg"))
|
||||
})
|
||||
|
||||
It("prefers shallower paths when the base filenames tie", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}},
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg"))
|
||||
})
|
||||
|
||||
It("sorts case-insensitively", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg"))
|
||||
})
|
||||
|
||||
It("reports the newest ImagesUpdatedAt across the folders", func() {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
newest := now.Add(5 * time.Minute)
|
||||
|
||||
_, updatedAt := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImagesUpdatedAt: now},
|
||||
{Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest},
|
||||
})
|
||||
|
||||
Expect(updatedAt).To(Equal(newest))
|
||||
})
|
||||
})
|
||||
210
core/artwork/folders_artist.go
Normal file
210
core/artwork/folders_artist.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const (
|
||||
maxArtistFolderTraversalDepth = 3
|
||||
)
|
||||
|
||||
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if libFS == nil {
|
||||
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
||||
}
|
||||
rel, err := filepath.Rel(libPath, artistFolder)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
||||
}
|
||||
// fs.Glob needs forward slashes; filepath.Rel returns backslashes on Windows.
|
||||
rel = filepath.ToSlash(rel)
|
||||
current := artistFolder
|
||||
var unreadable error
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
|
||||
if err == nil {
|
||||
return reader, hit, nil
|
||||
}
|
||||
if errors.Is(err, errSourceUnreadable) {
|
||||
unreadable = err
|
||||
}
|
||||
if rel == "." {
|
||||
break // reached library root
|
||||
}
|
||||
rel = path.Dir(rel)
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
if unreadable != nil {
|
||||
return nil, "", unreadable
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInFolder returns the first image matching pattern; absFolder is only used for
|
||||
// the returned display path and log messages.
|
||||
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder)
|
||||
globPattern := pattern
|
||||
if relFolder != "." {
|
||||
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
|
||||
}
|
||||
matches, err := fs.Glob(libFS, globPattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
imagePaths := slice.Filter(matches, model.IsImageFile)
|
||||
|
||||
// Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg)
|
||||
slices.SortFunc(imagePaths, compareImageFiles)
|
||||
|
||||
var openErr error
|
||||
for _, p := range imagePaths {
|
||||
f, err := libFS.Open(p)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err)
|
||||
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err)
|
||||
continue
|
||||
}
|
||||
_, name := path.Split(p)
|
||||
return f, filepath.Join(absFolder, name), nil
|
||||
}
|
||||
if openErr != nil {
|
||||
return nil, "", openErr
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
|
||||
}
|
||||
|
||||
func escapeGlobLiteral(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '*', '?', '[', ']':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// loadArtistAlbumRoots returns one path per album — the deepest folder holding
|
||||
// all of that album's tracks — so an album split into disc subfolders can't
|
||||
// pull the artist folder's common prefix below the artist level.
|
||||
func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) {
|
||||
var folderIDs []string
|
||||
for _, album := range albums {
|
||||
folderIDs = append(folderIDs, album.FolderIDs...)
|
||||
}
|
||||
folders, err := loadFolders(ctx, ds, folderIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) {
|
||||
return f.ID, f.AbsolutePath()
|
||||
})
|
||||
var roots []string
|
||||
for _, album := range albums {
|
||||
var albumPaths []string
|
||||
for _, fid := range album.FolderIDs {
|
||||
if p, ok := pathByID[fid]; ok {
|
||||
albumPaths = append(albumPaths, p)
|
||||
}
|
||||
}
|
||||
if len(albumPaths) > 0 {
|
||||
roots = append(roots, commonDir(albumPaths))
|
||||
}
|
||||
}
|
||||
|
||||
imgFiles, updatedAt := folderImages(folders)
|
||||
return roots, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// commonDir returns the deepest directory containing all paths. Trailing
|
||||
// separators keep the comparison on segment boundaries, so a shared name
|
||||
// fragment (".../Album" and ".../Album2") is never read as a shared directory.
|
||||
func commonDir(paths []string) string {
|
||||
sep := string(filepath.Separator)
|
||||
common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep }))
|
||||
if !strings.HasSuffix(common, sep) {
|
||||
common, _ = filepath.Split(common)
|
||||
}
|
||||
return filepath.Clean(common)
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
if len(albums) == 0 {
|
||||
return "", time.Time{}, nil
|
||||
}
|
||||
libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries
|
||||
|
||||
// paths holds one root per album: two or more distinct roots already meet at
|
||||
// the artist folder, while a single root is an album folder needing a climb.
|
||||
roots := slices.Compact(slices.Sorted(slices.Values(paths)))
|
||||
folderPath := commonDir(roots)
|
||||
if len(roots) < 2 {
|
||||
folderPath = filepath.Dir(folderPath)
|
||||
}
|
||||
|
||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
|
||||
// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none.
|
||||
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
base := utils.BaseName(name)
|
||||
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||
return filepath.Join(folder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
117
core/artwork/folders_artist_paths_test.go
Normal file
117
core/artwork/folders_artist_paths_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout
|
||||
// can only show the image that won, so the arithmetic is pinned here.
|
||||
var _ = Describe("commonDir", func() {
|
||||
It("returns the folder itself for a single path", func() {
|
||||
Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})).
|
||||
To(Equal(filepath.FromSlash("/music/artist/album")))
|
||||
})
|
||||
|
||||
It("returns the deepest shared folder", func() {
|
||||
Expect(commonDir([]string{
|
||||
filepath.FromSlash("/music/artist/album/cd1"),
|
||||
filepath.FromSlash("/music/artist/album/cd2"),
|
||||
})).To(Equal(filepath.FromSlash("/music/artist/album")))
|
||||
})
|
||||
|
||||
It("does not read a shared name fragment as a shared folder", func() {
|
||||
Expect(commonDir([]string{
|
||||
filepath.FromSlash("/music/artist/Album"),
|
||||
filepath.FromSlash("/music/artist/Album2"),
|
||||
})).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
repo *fakeFolderRepo
|
||||
albums model.Albums
|
||||
updatedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||
})
|
||||
|
||||
It("returns empty when the artist has no albums", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
|
||||
It("climbs above the album folder when the artist has a single album root", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums,
|
||||
[]string{filepath.FromSlash("/music/artist/album1")})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("climbs above the shared folder when two albums live in the same one", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("stops at the folder where distinct album roots already meet", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("returns the error when the folder lookup fails", func() {
|
||||
repo.err = errors.New("fake error")
|
||||
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
})
|
||||
|
||||
Expect(err).To(MatchError(ContainSubstring("fake error")))
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
func stubCoreAbsolutePath() func() {
|
||||
original := core.AbsolutePath
|
||||
core.AbsolutePath = func(context.Context, model.DataStore, int, string) string {
|
||||
return filepath.FromSlash("/music")
|
||||
}
|
||||
return func() { core.AbsolutePath = original }
|
||||
}
|
||||
47
core/artwork/folders_artist_test.go
Normal file
47
core/artwork/folders_artist_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing/fstest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error
|
||||
// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows.
|
||||
type unreadableFS struct{ fstest.MapFS }
|
||||
|
||||
func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
||||
|
||||
var _ = Describe("findImageInFolder", func() {
|
||||
var ctx context.Context
|
||||
var files fstest.MapFS
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}}
|
||||
})
|
||||
|
||||
It("returns the first matching image", func() {
|
||||
r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
Expect(hit).To(HaveSuffix("artist.jpg"))
|
||||
})
|
||||
|
||||
// The glob matched, so the image exists; failing to open it says nothing about whether the
|
||||
// artist has one, and must not let the resolver settle on absent.
|
||||
It("reports a matched but unreadable image as unreadable, not as a miss", func() {
|
||||
_, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*")
|
||||
Expect(err).To(MatchError(errSourceUnreadable))
|
||||
})
|
||||
|
||||
It("reports a plain miss when nothing matches", func() {
|
||||
_, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient")
|
||||
})
|
||||
})
|
||||
120
core/artwork/gate.go
Normal file
120
core/artwork/gate.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
breakerThreshold = 5
|
||||
breakerProbeAfter = time.Minute
|
||||
)
|
||||
|
||||
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
|
||||
|
||||
// gateFunc gates one named external fetch (rate limit + circuit breaker per name).
|
||||
type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
|
||||
|
||||
func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return f()
|
||||
}
|
||||
|
||||
// isTransientExternal reports whether an external failure is worth retrying; a not-found
|
||||
// (from either package) is a definitive answer, not a fault.
|
||||
func isTransientExternal(err error) bool {
|
||||
return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound)
|
||||
}
|
||||
|
||||
// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off
|
||||
// in isolation from the others.
|
||||
type extGate struct {
|
||||
limiter *rate.Limiter
|
||||
breaker *breaker
|
||||
}
|
||||
|
||||
// gate runs a named external step through that agent's rate limiter and circuit breaker.
|
||||
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
g := w.gateFor(name)
|
||||
if !g.breaker.allow() {
|
||||
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
|
||||
return nil, "", errBreakerOpen
|
||||
}
|
||||
// Timed separately so a throttled agent isn't mistaken for a slow provider.
|
||||
waitStart := time.Now()
|
||||
if err := g.limiter.Wait(w.runCtx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
callStart := time.Now()
|
||||
r, path, err := f()
|
||||
g.breaker.record(name, err)
|
||||
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
|
||||
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
|
||||
return r, path, err
|
||||
}
|
||||
|
||||
// gateFor lazily creates the per-name gate on first use.
|
||||
func (w *Worker) gateFor(name string) *extGate {
|
||||
w.gatesMu.Lock()
|
||||
defer w.gatesMu.Unlock()
|
||||
if g, ok := w.gates[name]; ok {
|
||||
return g
|
||||
}
|
||||
rps := conf.Server.DevArtworkExternalMaxRPS
|
||||
limit := rate.Inf
|
||||
if rps > 0 {
|
||||
limit = rate.Limit(rps)
|
||||
}
|
||||
g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()}
|
||||
w.gates[name] = g
|
||||
return g
|
||||
}
|
||||
|
||||
// breaker opens after breakerThreshold consecutive errors and admits a single probe once
|
||||
// breakerProbeAfter has elapsed; a success re-closes it.
|
||||
type breaker struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
func newBreaker() *breaker { return &breaker{} }
|
||||
|
||||
func (b *breaker) allow() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failures < breakerThreshold {
|
||||
return true
|
||||
}
|
||||
if time.Since(b.openedAt) >= breakerProbeAfter {
|
||||
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *breaker) record(name string, err error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
// A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal.
|
||||
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
|
||||
if b.failures >= breakerThreshold {
|
||||
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
|
||||
}
|
||||
b.failures = 0
|
||||
return
|
||||
}
|
||||
b.failures++
|
||||
if b.failures == breakerThreshold {
|
||||
b.openedAt = time.Now()
|
||||
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
|
||||
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
|
||||
}
|
||||
}
|
||||
51
core/artwork/hash_encoder_bench_test.go
Normal file
51
core/artwork/hash_encoder_bench_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/navidrome/navidrome/core/artwork/thumbhash"
|
||||
)
|
||||
|
||||
// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail.
|
||||
var hashEncoders = []struct {
|
||||
name string
|
||||
encode func(image.Image) error
|
||||
}{
|
||||
{"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }},
|
||||
{"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }},
|
||||
}
|
||||
|
||||
func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if err := encode(img); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image
|
||||
// makeThumbnail produces, so neither is measured with a conversion the other avoids.
|
||||
func BenchmarkHashEncodersAtInputSize(b *testing.B) {
|
||||
img := gradientNRGBA(thumbnailSize)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) })
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive
|
||||
// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above.
|
||||
func BenchmarkHashEncoders(b *testing.B) {
|
||||
for _, size := range []int{300, 600, 900, 1200, 1500} {
|
||||
img := gradientNRGBA(size)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) {
|
||||
benchEncoder(b, e.encode, img)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
129
core/artwork/housekeeping.go
Normal file
129
core/artwork/housekeeping.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const staleAbsentAge = 24 * time.Hour
|
||||
|
||||
// recheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
||||
var recheckKinds = []model.Kind{
|
||||
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
|
||||
}
|
||||
|
||||
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
|
||||
func hasRecheckPath(prefix string) bool {
|
||||
kind, ok := model.ParseKind(prefix)
|
||||
return ok && slices.Contains(recheckKinds, kind)
|
||||
}
|
||||
|
||||
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
|
||||
const artworkEpoch = 1
|
||||
|
||||
// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
|
||||
func fingerprint() string {
|
||||
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d",
|
||||
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
|
||||
conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch)
|
||||
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
|
||||
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
||||
start := time.Now()
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
current := fingerprint()
|
||||
props := ds.Property(ctx)
|
||||
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if stored == current {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Artists first: few entities, most external-dependent, so they get a queue headstart.
|
||||
kinds := []struct {
|
||||
kind model.Kind
|
||||
fetch func() ([]string, error)
|
||||
}{
|
||||
{model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
|
||||
{model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
|
||||
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
||||
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
||||
}
|
||||
for _, k := range kinds {
|
||||
ids, err := k.fetch()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
|
||||
return false, err
|
||||
}
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := slice.Map(ids, func(id string) model.ArtworkQueueItem {
|
||||
return model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
|
||||
}
|
||||
})
|
||||
return ds.ArtworkQueue(ctx).Enqueue(items...)
|
||||
}
|
||||
|
||||
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
||||
cutoff := time.Now().Add(-staleAbsentAge)
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range recheckKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range recheckKinds {
|
||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
||||
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
||||
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
|
||||
return fmt.Errorf("clearing artwork state: %w", err)
|
||||
}
|
||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
261
core/artwork/housekeeping_test.go
Normal file
261
core/artwork/housekeeping_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
|
||||
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
|
||||
type visibilityPlaylistDS struct {
|
||||
*tests.MockDataStore
|
||||
private model.Playlist
|
||||
tracks model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
repo := tests.CreateMockPlaylistRepo()
|
||||
repo.TracksRepo = v.tracks
|
||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
||||
repo.SetData(model.Playlists{v.private})
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func adminUserRepo() *tests.MockedUserRepo {
|
||||
repo := tests.CreateMockUserRepo()
|
||||
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
return repo
|
||||
}
|
||||
|
||||
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
||||
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
||||
type orderTrackingQueueRepo struct {
|
||||
*tests.MockArtworkQueueRepo
|
||||
callKinds []string
|
||||
}
|
||||
|
||||
func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||
if len(items) > 0 {
|
||||
o.callKinds = append(o.callKinds, items[0].ItemKind)
|
||||
}
|
||||
return o.MockArtworkQueueRepo.Enqueue(items...)
|
||||
}
|
||||
|
||||
var _ = Describe("Housekeeping", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
queueRepo *orderTrackingQueueRepo
|
||||
propRepo *tests.MockedPropertyRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
conf.Server.CoverArtPriority = "embedded, folder"
|
||||
conf.Server.ArtistArtPriority = "artist.jpg"
|
||||
conf.Server.Agents = "spotify"
|
||||
conf.Server.EnableExternalServices = true
|
||||
|
||||
queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()}
|
||||
propRepo = &tests.MockedPropertyRepo{}
|
||||
ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo}
|
||||
})
|
||||
|
||||
seedEntities := func() {
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
||||
ds.MockedAlbum = albumRepo
|
||||
|
||||
playlistRepo := tests.CreateMockPlaylistRepo()
|
||||
playlistRepo.SetData(model.Playlists{{ID: "pl1"}})
|
||||
ds.MockedPlaylist = playlistRepo
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.All = model.Radios{{ID: "ra1"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
}
|
||||
|
||||
Describe("Fingerprint", func() {
|
||||
It("changes when a fingerprint-affecting config value changes", func() {
|
||||
f1 := fingerprint()
|
||||
conf.Server.CoverArtPriority = "folder, embedded"
|
||||
f2 := fingerprint()
|
||||
Expect(f1).NotTo(Equal(f2))
|
||||
})
|
||||
|
||||
It("changes when ArtistImageFolder changes", func() {
|
||||
conf.Server.ArtistImageFolder = "/before"
|
||||
f1 := fingerprint()
|
||||
conf.Server.ArtistImageFolder = "/after"
|
||||
Expect(fingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
f1 := fingerprint()
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(fingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
It("does not change when the server version changes", func() {
|
||||
original := consts.Version
|
||||
DeferCleanup(func() { consts.Version = original })
|
||||
f1 := fingerprint()
|
||||
consts.Version = original + "-next"
|
||||
Expect(fingerprint()).To(Equal(f1),
|
||||
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Backfill", func() {
|
||||
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeFalse())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
})
|
||||
|
||||
It("runs the backfill when no fingerprint was ever stored", func() {
|
||||
seedEntities()
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio
|
||||
|
||||
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).To(Equal(fingerprint()))
|
||||
})
|
||||
|
||||
It("enqueues a private playlist by resolving it under an admin context", func() {
|
||||
ds.MockedUser = adminUserRepo()
|
||||
vds := &visibilityPlaylistDS{
|
||||
MockDataStore: ds,
|
||||
private: model.Playlist{ID: "plPrivate", OwnerID: "admin"},
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
|
||||
did, err := backfill(ctx, vds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
|
||||
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
||||
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
|
||||
Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call")
|
||||
if firstOther >= 0 {
|
||||
Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"),
|
||||
"no artist Enqueue may follow another kind")
|
||||
}
|
||||
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill))
|
||||
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueStaleAbsentAll", func() {
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds.MockedArtwork = artRepo
|
||||
queueRepo.ItemArtworkSource = artRepo
|
||||
})
|
||||
|
||||
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
recent := time.Now().Add(-time.Hour)
|
||||
|
||||
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
|
||||
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
|
||||
|
||||
err := enqueueStaleAbsentAll(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(4))
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
}
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueMissingAll", func() {
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds.MockedArtwork = artRepo
|
||||
queueRepo.ItemArtworkSource = artRepo
|
||||
queueRepo.ExistingIDs = map[string]map[string]bool{
|
||||
"al": {"al1": true, "al2": true},
|
||||
"ar": {"ar1": true},
|
||||
"pl": {"pl1": true},
|
||||
"ra": {"ra1": true},
|
||||
}
|
||||
})
|
||||
|
||||
It("enqueues only entities that have no item_artwork row, across all kinds", func() {
|
||||
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()}
|
||||
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}
|
||||
|
||||
err := enqueueMissingAll(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
}
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,30 +1,22 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type cacheKey struct {
|
||||
artID model.ArtworkID
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (k *cacheKey) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s-%s.%d",
|
||||
k.artID.Kind,
|
||||
k.artID.ID,
|
||||
k.lastUpdate.UnixMilli(),
|
||||
)
|
||||
// artworkReader is the cache.Item the image cache loader dispatches on: Reader
|
||||
// produces the (possibly resized) bytes to store under Key.
|
||||
type artworkReader interface {
|
||||
cache.Item
|
||||
Reader(ctx context.Context) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type imageCache struct {
|
||||
|
|
@ -36,9 +28,45 @@ func GetImageCache() cache.FileCache {
|
|||
return &imageCache{
|
||||
FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
return arg.(artworkReader).Reader(ctx)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resizedItem is an artworkReader that resizes bytes opened by open() and caches the
|
||||
// result under a hash-derived key.
|
||||
type resizedItem struct {
|
||||
hash string
|
||||
size int
|
||||
square bool
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
open func() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it
|
||||
// validates can never drift apart.
|
||||
func (r *resizedItem) Key() string {
|
||||
return "h-" + representationTag(r.hash, r.size, r.square)
|
||||
}
|
||||
|
||||
func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
|
||||
orig, err := r.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer orig.Close()
|
||||
data, err := readCapped(orig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square)
|
||||
if err != nil || resized == nil {
|
||||
// Resize failed or image already within bounds: serve the original bytes.
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
if rc, ok := resized.(io.ReadCloser); ok {
|
||||
return rc, nil
|
||||
}
|
||||
return io.NopCloser(resized), nil
|
||||
}
|
||||
|
|
|
|||
155
core/artwork/image_store.go
Normal file
155
core/artwork/image_store.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
// ImageStore is the content-addressed store for artwork images with no library file backing them.
|
||||
type ImageStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewImageStore(rootDir string) *ImageStore {
|
||||
return &ImageStore{root: rootDir}
|
||||
}
|
||||
|
||||
func GetImageStore() *ImageStore {
|
||||
return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder))
|
||||
}
|
||||
|
||||
// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open.
|
||||
func extForMime(m string) string {
|
||||
switch m {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
}
|
||||
return ".img"
|
||||
}
|
||||
|
||||
func hashImage(r io.Reader) (string, error) {
|
||||
d := xxh3.New()
|
||||
if _, err := io.Copy(d, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%016x", d.Sum64()), nil
|
||||
}
|
||||
|
||||
// validHash guards path sharding: a malformed hash would slice-panic or inject path separators.
|
||||
func validHash(hash string) bool {
|
||||
if len(hash) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range []byte(hash) {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *ImageStore) path(hash, mimeType string) string {
|
||||
return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType))
|
||||
}
|
||||
|
||||
func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
||||
if !validHash(hash) {
|
||||
return fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
dst := s.path(hash, mimeType)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
// A touched mtime marks the file live so a concurrent prune spares it.
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(dst, now, now); err == nil {
|
||||
return nil
|
||||
}
|
||||
// touch failed (likely pruned concurrently) — fall through and rewrite it
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
|
||||
if !validHash(hash) {
|
||||
return nil, fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
return os.Open(s.path(hash, mimeType))
|
||||
}
|
||||
|
||||
// Sweep removes store files not accepted by keep. Files modified after cutoff are always
|
||||
// kept: their acquisition row may not be committed yet.
|
||||
func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) {
|
||||
removed, failed := 0, 0
|
||||
var lastErr error
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window
|
||||
if !remove {
|
||||
ext := filepath.Ext(name)
|
||||
remove = !keep(strings.TrimSuffix(name, ext), ext)
|
||||
}
|
||||
if remove {
|
||||
// #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks
|
||||
if err := os.Remove(path); err != nil {
|
||||
// One unremovable file must not strand the rest of the store until the next prune.
|
||||
failed, lastErr = failed+1, err
|
||||
return nil //nolint:nilerr // counted and reported in aggregate below
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Aggregated: a store that has gone read-only would otherwise warn once per file, every prune.
|
||||
if failed > 0 {
|
||||
log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr)
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return removed, nil
|
||||
}
|
||||
return removed, err
|
||||
}
|
||||
228
core/artwork/image_store_test.go
Normal file
228
core/artwork/image_store_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageStore", func() {
|
||||
var store *ImageStore
|
||||
var root string
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
root = GinkgoT().TempDir()
|
||||
store = NewImageStore(root)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
It("hashes deterministically", func() {
|
||||
h1, err := hashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
h2, _ := hashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(h1).To(Equal(h2))
|
||||
Expect(h1).To(HaveLen(16))
|
||||
h3, _ := hashImage(bytes.NewReader([]byte("other bytes")))
|
||||
Expect(h3).ToNot(Equal(h1))
|
||||
})
|
||||
|
||||
It("writes sharded and reads back", func() {
|
||||
data := []byte("jpeg-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, _ := io.ReadAll(rc)
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("is idempotent on duplicate writes and preserves the original content", func() {
|
||||
data := []byte("dup")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
// A duplicate write only touches mtime; passing different bytes under the same
|
||||
// hash proves the second reader is never consumed to overwrite the file.
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, err := io.ReadAll(rc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("refreshes the mtime on a duplicate write", func() {
|
||||
data := []byte("touch-me")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
info, err := os.Stat(store.path(h, "image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute)))
|
||||
})
|
||||
|
||||
It("rewrites the bytes when the existing file vanished before the liveness touch", func() {
|
||||
data := []byte("vanishing")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
for range 10 {
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(os.Remove(store.path(h, "image/png"))).To(Succeed())
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
got, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
Expect(got).To(Equal(data))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns fs.ErrNotExist for missing images", func() {
|
||||
_, err := store.Open("beefbeefbeefbeef", "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects invalid hashes instead of panicking", func() {
|
||||
for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} {
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash")))
|
||||
_, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid hash")))
|
||||
}
|
||||
})
|
||||
|
||||
It("sweeps unknown files, keeps known ones", func() {
|
||||
d1 := []byte("keep-me")
|
||||
h1, _ := hashImage(bytes.NewReader(d1))
|
||||
Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed())
|
||||
d2 := []byte("orphan")
|
||||
h2, _ := hashImage(bytes.NewReader(d2))
|
||||
Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed())
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h2, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h1, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("sweeps a stale mime variant of a known hash, keeps the current one", func() {
|
||||
data := []byte("same-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
// The recorded mime is image/jpeg, so the .png variant is obsolete.
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool {
|
||||
return hash == h && ext == ".jpg"
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h, "image/png")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("keeps young unknown files inside the grace window", func() {
|
||||
d := []byte("fresh-orphan")
|
||||
h, _ := hashImage(bytes.NewReader(d))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(0))
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("removes abandoned temp files past the grace window, keeps fresh ones", func() {
|
||||
oldTmp := filepath.Join(root, ".old.tmp")
|
||||
Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(oldTmp, old, old)).To(Succeed())
|
||||
|
||||
freshTmp := filepath.Join(root, ".fresh.tmp")
|
||||
Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
Expect(oldTmp).ToNot(BeAnExistingFile())
|
||||
Expect(freshTmp).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("keeps sweeping past a file it cannot remove", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
if os.Geteuid() == 0 {
|
||||
Skip("read-only dir cannot block root (e.g. tests in a container)")
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
// "blocked" sorts before "ok", so the walk hits the unremovable file first.
|
||||
blockedDir := filepath.Join(root, "blocked")
|
||||
Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed())
|
||||
blocked := filepath.Join(blockedDir, "a.jpg")
|
||||
Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(blocked, old, old)).To(Succeed())
|
||||
|
||||
okDir := filepath.Join(root, "ok")
|
||||
Expect(os.MkdirAll(okDir, 0755)).To(Succeed())
|
||||
reachable := filepath.Join(okDir, "b.jpg")
|
||||
Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(reachable, old, old)).To(Succeed())
|
||||
|
||||
Expect(os.Chmod(blockedDir, 0500)).To(Succeed())
|
||||
DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) })
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
Expect(blocked).To(BeAnExistingFile())
|
||||
Expect(reachable).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
// Prune holds the worker's write lock for the whole sweep, and shutdown waits on the
|
||||
// worker, so an uncancellable walk over a large store stalls it until SIGKILL.
|
||||
It("abandons the walk when the context is cancelled", func() {
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
for _, name := range []string{"a", "b", "c", "d"} {
|
||||
p := filepath.Join(root, name+".jpg")
|
||||
Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(p, old, old)).To(Succeed())
|
||||
}
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
_, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
matches, _ := filepath.Glob(filepath.Join(root, "*.jpg"))
|
||||
Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting")
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,9 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -40,5 +42,23 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar
|
|||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
return libraryView{FS: fs, absRoot: lib.Path}, nil
|
||||
return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil
|
||||
}
|
||||
|
||||
// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept:
|
||||
// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged).
|
||||
func localOSRoot(libPath string) string {
|
||||
if !strings.Contains(libPath, "://") {
|
||||
return libPath
|
||||
}
|
||||
u, err := url.Parse(libPath)
|
||||
if err != nil || u.Scheme != storage.LocalSchemaID {
|
||||
return libPath
|
||||
}
|
||||
// Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching
|
||||
// core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path.
|
||||
if filepath.VolumeName(u.Host) != "" {
|
||||
return filepath.Join(u.Host, u.Path)
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
|||
Expect(lib.absRoot).To(Equal("fake:///music"))
|
||||
})
|
||||
|
||||
It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() {
|
||||
// file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged.
|
||||
Expect(localOSRoot("file:///music/library")).To(Equal("/music/library"))
|
||||
Expect(localOSRoot("/music/library")).To(Equal("/music/library"))
|
||||
Expect(localOSRoot("fake:///music")).To(Equal("fake:///music"))
|
||||
})
|
||||
|
||||
It("returns an error when the library does not exist", func() {
|
||||
_, err := loadLibraryView(ctx, ds, 999)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
|
|
|||
116
core/artwork/playlist_cover.go
Normal file
116
core/artwork/playlist_cover.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const tileSize = 600
|
||||
|
||||
// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive).
|
||||
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
|
||||
if plsPath == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(plsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err)
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
nameBase := utils.BaseName(name)
|
||||
if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rect(pos int) image.Rectangle {
|
||||
r := image.Rectangle{}
|
||||
switch pos {
|
||||
case 1:
|
||||
r.Min.X = tileSize / 2
|
||||
case 2:
|
||||
r.Min.Y = tileSize / 2
|
||||
case 3:
|
||||
r.Min.X = tileSize / 2
|
||||
r.Min.Y = tileSize / 2
|
||||
}
|
||||
r.Max.X = r.Min.X + tileSize/2
|
||||
r.Max.Y = r.Min.Y + tileSize/2
|
||||
return r
|
||||
}
|
||||
|
||||
// fillCenter center-crops src and scales it to fill dstW x dstH exactly.
|
||||
func fillCenter(src image.Image, dstW, dstH int) image.Image {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
srcAspect := float64(srcW) / float64(srcH)
|
||||
dstAspect := float64(dstW) / float64(dstH)
|
||||
|
||||
var cropRect image.Rectangle
|
||||
if srcAspect > dstAspect {
|
||||
cropW := int(float64(srcH) * dstAspect)
|
||||
cropX := (srcW - cropW) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
|
||||
} else {
|
||||
cropH := int(float64(srcW) / dstAspect)
|
||||
cropY := (srcH - cropH) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
|
||||
}
|
||||
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// decodeTile runs before the processor's size guards apply, so it enforces the caps itself.
|
||||
func decodeTile(r io.ReadCloser) (image.Image, error) {
|
||||
data, err := readCapped(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, _, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func assembleTiles(tiles []image.Image) (io.ReadCloser, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
var err error
|
||||
if len(tiles) == 4 {
|
||||
rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||
err = png.Encode(buf, rgba)
|
||||
} else {
|
||||
err = png.Encode(buf, tiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
311
core/artwork/processor.go
Normal file
311
core/artwork/processor.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
_ "image/gif" // the only artwork format with no other importer in this package
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/navidrome/navidrome/core/artwork/dominant"
|
||||
"github.com/navidrome/navidrome/core/artwork/thumbhash"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it.
|
||||
type outcome int
|
||||
|
||||
const (
|
||||
outcomeFound outcome = iota
|
||||
// outcomeFoundStale: state was written and is served, but a higher-priority external
|
||||
// source failed, so the row must retry to give it another chance.
|
||||
outcomeFoundStale
|
||||
outcomeAbsent
|
||||
outcomeFailed
|
||||
)
|
||||
|
||||
func (o outcome) String() string {
|
||||
switch o {
|
||||
case outcomeFound:
|
||||
return "found"
|
||||
case outcomeFoundStale:
|
||||
return "foundStale"
|
||||
case outcomeAbsent:
|
||||
return "absent"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
|
||||
// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger.
|
||||
const thumbnailSize = 100
|
||||
|
||||
// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at
|
||||
// an arbitrarily large endpoint.
|
||||
const maxImageBytes = 20 << 20
|
||||
|
||||
// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that
|
||||
// image.Decode would expand into gigabytes.
|
||||
const maxImagePixels = 64 << 20
|
||||
|
||||
// acquired is what a successful acquire persisted, handed back so the caller can warm the resize
|
||||
// cache without re-reading the rows and the file it just wrote.
|
||||
type acquired struct {
|
||||
ia *model.ItemArtwork
|
||||
mime string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// processor turns one queue item into stored artwork; settling the queue row is the Worker's job.
|
||||
type processor struct {
|
||||
ds model.DataStore
|
||||
store *ImageStore
|
||||
resolver *resolver
|
||||
pruneLock sync.Locker
|
||||
}
|
||||
|
||||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||
// blurhash it, place its bytes, and persist the resulting state.
|
||||
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) {
|
||||
repo := p.ds.Artwork(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "elapsed", time.Since(start))
|
||||
}()
|
||||
|
||||
res, err := p.resolver.resolve(ctx, item)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
if res.reader == nil {
|
||||
if res.extError || res.localError {
|
||||
// A fault is not a definitive "no image": never settle absent, keep serving old state.
|
||||
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
|
||||
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
return writeAbsent(ctx, repo, item), nil
|
||||
}
|
||||
defer res.reader.Close()
|
||||
|
||||
readStart := time.Now()
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"source", res.source, "bytes", len(data), "elapsed", time.Since(readStart))
|
||||
|
||||
hashStart := time.Now()
|
||||
hash, err := hashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||
|
||||
art, err := repo.GetImage(hash)
|
||||
switch {
|
||||
case err == nil:
|
||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||
"id", item.ItemID, "hash", hash)
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
decodeStart := time.Now()
|
||||
art, err = decodeArtwork(ctx, hash, data)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
|
||||
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
|
||||
default:
|
||||
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
art.SizeBytes = int64(len(data))
|
||||
|
||||
ia, err := p.persist(repo, item, art, res, data)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
got = &acquired{ia: ia, mime: art.Mime, data: data}
|
||||
if res.extError {
|
||||
log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure",
|
||||
"kind", item.ItemKind, "id", item.ItemID, "source", res.source)
|
||||
return outcomeFoundStale, got
|
||||
}
|
||||
return outcomeFound, got
|
||||
}
|
||||
|
||||
// persist places the bytes and commits the rows referencing them, excluding Prune for that
|
||||
// window only so a slow resolution can never hold it off.
|
||||
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
|
||||
art *model.Artwork, res resolution, data []byte,
|
||||
) (*model.ItemArtwork, error) {
|
||||
if p.pruneLock != nil {
|
||||
p.pruneLock.Lock()
|
||||
defer p.pruneLock.Unlock()
|
||||
}
|
||||
sourcePath, refMtime, err := placeBytes(p.store, art, res, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("writing image store: %w", err)
|
||||
}
|
||||
if err := repo.PutImage(art); err != nil {
|
||||
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
||||
}
|
||||
ia := &model.ItemArtwork{
|
||||
ItemKind: item.ItemKind,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
Hash: art.Hash,
|
||||
Source: res.source,
|
||||
SourcePath: sourcePath,
|
||||
RefMtime: refMtime,
|
||||
AttemptedAt: time.Now(),
|
||||
}
|
||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||
if err := repo.PutItemArtwork(ia); err != nil {
|
||||
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
||||
}
|
||||
return ia, nil
|
||||
}
|
||||
|
||||
// writeAbsent records a known-absent state: every source answered definitively "no".
|
||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||
err := repo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: item.ItemKind,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
AttemptedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Settled absent, every source answered definitively",
|
||||
"kind", item.ItemKind, "id", item.ItemID)
|
||||
return outcomeAbsent
|
||||
}
|
||||
|
||||
func readCapped(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxImageBytes {
|
||||
return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation.
|
||||
func decodeCapped(data []byte) (image.Image, string, error) {
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image config: %w", err)
|
||||
}
|
||||
// Compared by division so the cap cannot be defeated by an int64 overflow.
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height {
|
||||
return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two
|
||||
// placeholder hashes, both encoded from one shared downscaled thumbnail.
|
||||
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
|
||||
img, format, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
thumb := makeThumbnail(img, thumbnailSize)
|
||||
bh, err := blurhash.Encode(thumb)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err)
|
||||
bh = ""
|
||||
}
|
||||
|
||||
var th string
|
||||
if raw, err := thumbhash.Encode(thumb); err != nil {
|
||||
log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err)
|
||||
} else {
|
||||
th = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
return &model.Artwork{
|
||||
Hash: hash,
|
||||
Mime: mimeForFormat(format),
|
||||
Width: img.Bounds().Dx(),
|
||||
Height: img.Bounds().Dy(),
|
||||
BlurHash: bh,
|
||||
ThumbHash: th,
|
||||
DominantColor: dominant.Color(thumb),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales.
|
||||
func makeThumbnail(img image.Image, maxSize int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxSize && h <= maxSize {
|
||||
return toFastScaleType(img)
|
||||
}
|
||||
scale := float64(maxSize) / float64(max(w, h))
|
||||
// NRGBA, not RGBA: thumbhash requires straight alpha, and blurhash reads this type without
|
||||
// converting, so neither encoder allocates a second copy of the thumbnail.
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// isFileBacked reports whether the bytes already live in a library/upload file, so the
|
||||
// content-addressed store must not duplicate them.
|
||||
func isFileBacked(source string) bool {
|
||||
return source == "folder" || source == "upload"
|
||||
}
|
||||
|
||||
// placeBytes reports the item's backing-file provenance and writes the bytes into the store
|
||||
// for the sources that have none.
|
||||
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {
|
||||
if isFileBacked(res.source) {
|
||||
return res.sourcePath, res.refMtime, nil
|
||||
}
|
||||
if res.source == "embedded" {
|
||||
sourcePath, refMtime = res.sourcePath, res.refMtime
|
||||
}
|
||||
return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse.
|
||||
func mimeForFormat(format string) string {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
495
core/artwork/processor_test.go
Normal file
495
core/artwork/processor_test.go
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// DecodeConfig reads only the header, so the pixel data can be omitted entirely.
|
||||
func pngHeaderWithDims(w, h uint32) []byte {
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 2 // color type: truecolor
|
||||
chunk := append([]byte("IHDR"), ihdr...)
|
||||
out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr)))
|
||||
out = append(out, chunk...)
|
||||
return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk))
|
||||
}
|
||||
|
||||
var _ = Describe("processor.acquire", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
ag *agents.Agents
|
||||
store *ImageStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
repoRoot string
|
||||
proc *processor
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)}
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
})
|
||||
|
||||
It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
Expect(art.ThumbHash).ToNot(BeEmpty())
|
||||
Expect(art.DominantColor).To(MatchRegexp(`^#[0-9a-f]{6}$`))
|
||||
_, err = store.Open(ia.Hash, art.Mime)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store")
|
||||
})
|
||||
|
||||
Describe("prune lock scope", func() {
|
||||
var lock *countingLocker
|
||||
|
||||
BeforeEach(func() {
|
||||
lock = &countingLocker{}
|
||||
proc.pruneLock = lock
|
||||
})
|
||||
|
||||
It("holds it once across the write window", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune")
|
||||
Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns")
|
||||
})
|
||||
|
||||
// Resolution can reach the network, so holding the lock across it would let one slow
|
||||
// provider block prune, and every drain queued behind prune's writer.
|
||||
It("never takes it while only resolving", func() {
|
||||
folderRepo.result = nil
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"})
|
||||
Expect(out).To(Equal(outcomeAbsent))
|
||||
Expect(lock.locks).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
folderRepo.result = nil
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("absent: no local source and no external error persists a known-absent state", func() {
|
||||
folderRepo.result = nil
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al3", Name: "Album"},
|
||||
})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
Expect(out).To(Equal(outcomeAbsent))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(ia.Source).To(BeEmpty())
|
||||
Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
})
|
||||
|
||||
It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
// A folder listing that names a cover the FS will not hand over: a stale mount.
|
||||
libRoot := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
||||
})
|
||||
|
||||
// An upload outranks every source, so an unreadable one must not let a lower one take over.
|
||||
It("failed-on-unreadable-upload: an upload that will not open never records absent", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
// The file would still open, so the spec would pass on the decode error instead.
|
||||
Skip("chmod does not restrict read access on Windows")
|
||||
}
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{}
|
||||
ds.MockedRadio = radioRepo
|
||||
dir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(dir)
|
||||
upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg")
|
||||
Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed())
|
||||
Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable
|
||||
DeferCleanup(func() { _ = os.Chmod(upload, 0o600) })
|
||||
radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"}
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||
})
|
||||
|
||||
It("failed-on-extError: leaves the item's state untouched", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al4", Name: "Album"},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||
Expect(out).To(Equal(outcomeFoundStale))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(imgBytes)
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("external:deezerFake"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}},
|
||||
{ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||
Expect(out1).To(Equal(outcomeFound))
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||
poisoned := artRepo.Data[ia1.Hash]
|
||||
poisoned.BlurHash = "SENTINEL"
|
||||
artRepo.Data[ia1.Hash] = poisoned
|
||||
|
||||
out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||
Expect(out2).To(Equal(outcomeFound))
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||
|
||||
reused, err := artRepo.GetImage(ia1.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
||||
It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() {
|
||||
// Byte-identical files share one hash, but provenance is per item.
|
||||
libRoot := GinkgoT().TempDir()
|
||||
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} {
|
||||
dir := filepath.Join(libRoot, sub)
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
img := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed())
|
||||
}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}},
|
||||
{ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}},
|
||||
})
|
||||
|
||||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||
outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||
Expect(outN).To(Equal(outcomeFound))
|
||||
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaA.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||
|
||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||
poisoned := artRepo.Data[iaA.Hash]
|
||||
poisoned.BlurHash = "SENTINEL"
|
||||
artRepo.Data[iaA.Hash] = poisoned
|
||||
|
||||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||
outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||
Expect(outN).To(Equal(outcomeFound))
|
||||
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
||||
|
||||
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||
|
||||
Expect(artRepo.Data).To(HaveLen(1))
|
||||
reused, err := artRepo.GetImage(iaA.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
||||
It("decode failure on found bytes: fails without writing state", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("oversized read: a resolved image larger than the cap fails without writing state", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg")
|
||||
f, err := os.Create(imgPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(f.Truncate(maxImageBytes + 1)).To(Succeed())
|
||||
Expect(f.Close()).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
// The cap is compared by division so an overflowing product cannot slip past it; no supported
|
||||
// format can declare these dimensions, so this pins the arithmetic, not a live hole.
|
||||
DescribeTable("rejects out-of-range declared dimensions",
|
||||
func(w, h uint32) {
|
||||
_, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h))
|
||||
Expect(err).To(HaveOccurred())
|
||||
},
|
||||
Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)),
|
||||
Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)),
|
||||
Entry("zero width", uint32(0), uint32(100)),
|
||||
Entry("zero height", uint32(100), uint32(0)),
|
||||
)
|
||||
|
||||
It("decompression bomb: rejects huge declared dimensions before the full decode", func() {
|
||||
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
|
||||
_, err := decodeArtwork(ctx, "bomb", data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
||||
})
|
||||
|
||||
It("store write failure: fails without writing state", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
folderRepo.result = nil
|
||||
|
||||
// A store root that is a plain file makes every MkdirAll under it fail.
|
||||
blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir")
|
||||
Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed())
|
||||
proc.store = NewImageStore(blockedRoot)
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
type countingLocker struct {
|
||||
locks int
|
||||
unlocks int
|
||||
}
|
||||
|
||||
func (l *countingLocker) Lock() { l.locks++ }
|
||||
func (l *countingLocker) Unlock() { l.unlocks++ }
|
||||
func (l *countingLocker) held() bool { return l.locks != l.unlocks }
|
||||
|
||||
var _ = Describe("makeThumbnail", func() {
|
||||
// Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is
|
||||
// what keeps either of them from allocating a converted copy of the thumbnail.
|
||||
It("emits NRGBA when it downscales", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{}))
|
||||
})
|
||||
|
||||
It("fits the longest side to maxSize and keeps the aspect ratio", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
b := makeThumbnail(src, 100).Bounds()
|
||||
Expect(b.Dx()).To(Equal(100))
|
||||
Expect(b.Dy()).To(Equal(75))
|
||||
})
|
||||
|
||||
It("never upscales an image already within bounds", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 40, 30))
|
||||
Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds()))
|
||||
})
|
||||
|
||||
// A fully transparent pixel's colour cannot survive any resample, since the scaler works in
|
||||
// premultiplied space and multiplying by zero is not invertible. Partial alpha is the case
|
||||
// that distinguishes straight storage from premultiplied.
|
||||
It("keeps partly transparent colour straight rather than premultiplied", func() {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 400, 400))
|
||||
for y := range 400 {
|
||||
for x := range 400 {
|
||||
src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128})
|
||||
}
|
||||
}
|
||||
thumb, ok := makeThumbnail(src, 100).(*image.NRGBA)
|
||||
Expect(ok).To(BeTrue())
|
||||
// Premultiplied storage would have halved this to ~100.
|
||||
Expect(thumb.Pix[0]).To(BeNumerically("==", 200))
|
||||
Expect(thumb.Pix[3]).To(BeNumerically("==", 128))
|
||||
})
|
||||
})
|
||||
63
core/artwork/prune.go
Normal file
63
core/artwork/prune.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// pruneMinAge guards the window between artwork insert and item_artwork upsert.
|
||||
const pruneMinAge = time.Hour
|
||||
|
||||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||
start := time.Now()
|
||||
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
||||
repo := ds.Artwork(ctx)
|
||||
|
||||
purged, err := repo.PurgeDanglingItems()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if purged > 0 {
|
||||
log.Info(ctx, "Artwork: Purged dangling item state", "count", purged)
|
||||
}
|
||||
|
||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queuePurged > 0 {
|
||||
log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged)
|
||||
}
|
||||
|
||||
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
||||
cutoff := time.Now().Add(-pruneMinAge)
|
||||
orphans, err := repo.PurgeOrphans(cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if orphans > 0 {
|
||||
log.Info(ctx, "Artwork: Removed orphan image rows", "count", orphans)
|
||||
}
|
||||
|
||||
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
||||
mimes, err := repo.GetMimeByHash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool {
|
||||
// A known hash under a stale extension is a superseded mime variant — reclaim it.
|
||||
m, ok := mimes[hash]
|
||||
return ok && ext == extForMime(m)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if removed > 0 {
|
||||
log.Info(ctx, "Artwork: Swept stray files", "count", removed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
239
core/artwork/prune_test.go
Normal file
239
core/artwork/prune_test.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type flakyGetArtworkRepo struct {
|
||||
*tests.MockArtworkRepo
|
||||
}
|
||||
|
||||
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
|
||||
return nil, errors.New("db locked")
|
||||
}
|
||||
|
||||
var _ = Describe("Prune", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var store *ImageStore
|
||||
var awRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
})
|
||||
|
||||
// PutImage refreshes created_at like the SQL repo, so fixtures are aged directly.
|
||||
ageArtwork := func(h string, t time.Time) {
|
||||
a := awRepo.Data[h]
|
||||
a.CreatedAt = t
|
||||
awRepo.Data[h] = a
|
||||
}
|
||||
|
||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||
"al": {},
|
||||
"ar": {"live-artist": true},
|
||||
}
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||
Expect(queueRepo.Enqueue(
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||
)).To(Succeed())
|
||||
queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}}
|
||||
ds.MockedArtworkQueue = queueRepo
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil())
|
||||
Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("deletes orphan rows and their store files, keeps referenced ones", func() {
|
||||
data := []byte("orphan-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, old)
|
||||
|
||||
kept := []byte("kept-bytes")
|
||||
hk, _ := hashImage(bytes.NewReader(kept))
|
||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(hk, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares an aged row that item_artwork state still references", func() {
|
||||
data := []byte("reacquired-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares an unreferenced row recreated inside the grace window", func() {
|
||||
data := []byte("fresh-reacquired-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares an orphan file freshly touched by an overlapping acquisition", func() {
|
||||
data := []byte("racing-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("sweeps store files that have no artwork row", func() {
|
||||
stray := []byte("no-row-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(stray))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sweeps an obsolete mime variant of a reacquired hash", func() {
|
||||
data := []byte("variant-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
// The row records the current mime; the .png file is a superseded variant.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := store.Open(h, "image/png")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("reclaims the other orphan files when one of them cannot be removed", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
if os.Geteuid() == 0 {
|
||||
Skip("read-only dir cannot block root (e.g. tests in a container)")
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
|
||||
blocked := []byte("blocked-bytes")
|
||||
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hb, old)
|
||||
|
||||
good := []byte("good-bytes")
|
||||
hg, _ := hashImage(bytes.NewReader(good))
|
||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hg, old)
|
||||
|
||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||
shardDir := filepath.Dir(store.path(hb, "image/jpeg"))
|
||||
Expect(shardDir).ToNot(Equal(filepath.Dir(store.path(hg, "image/jpeg"))), "fixtures must land in different shards")
|
||||
Expect(os.Chmod(shardDir, 0500)).To(Succeed())
|
||||
DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) })
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(hg)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(hg, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
// The row purge does not depend on file removal, so only the file survives.
|
||||
_, err = awRepo.GetImage(hb)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
rc, err := store.Open(hb, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("never sweeps files on a transient DB error", func() {
|
||||
ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()}
|
||||
|
||||
data := []byte("live-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).ToNot(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album Artwork Reader", func() {
|
||||
Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
expectedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedAt = now.Add(5 * time.Minute)
|
||||
|
||||
// Set up the test folders with image files
|
||||
repo = &fakeFolderRepo{}
|
||||
ds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns sorted image files", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album/Disc1",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc10",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
|
||||
// Check that image files are sorted by base name (without extension)
|
||||
Expect(imgFiles).To(HaveLen(5))
|
||||
|
||||
// Files should be sorted by base filename without extension, then by full path
|
||||
// "back" < "cover", so back.jpg comes first
|
||||
// Then all cover.jpg files, sorted by path
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg"))
|
||||
Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg"))
|
||||
Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg"))
|
||||
})
|
||||
|
||||
It("prioritizes files without numeric suffixes", func() {
|
||||
// Test case for issue #4683: cover.jpg should come before cover.1.jpg
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg"))
|
||||
})
|
||||
|
||||
It("handles case-insensitive sorting", func() {
|
||||
// Test that Cover.jpg and cover.jpg are treated as equivalent
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// Files should be sorted case-insensitively: BACK, cover, Folder
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg"))
|
||||
})
|
||||
|
||||
It("includes images from parent folder for multi-disc albums", func() {
|
||||
// Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "parentFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(2))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
|
||||
})
|
||||
|
||||
It("does not query parent when parent ID is already in album folders", func() {
|
||||
// When the parent folder is already one of the album's folders, skip it
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "folder2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "",
|
||||
Name: "Artist",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
// Get should not have been called (parent already in folder set)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does not query parent when folders have different parents", func() {
|
||||
// When album folders span different parents, don't search any parent
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist1/Album",
|
||||
Name: "part1",
|
||||
ParentID: "parentA",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist2/Album",
|
||||
Name: "part2",
|
||||
ParentID: "parentB",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg"))
|
||||
// Get should not have been called (different parents)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does not include library root parent for multi-folder albums", func() {
|
||||
// Two album parts directly under the library root — parent is the root itself
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: ".",
|
||||
Name: "AlbumPart1",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: ".",
|
||||
Name: "AlbumPart2",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "rootFolder",
|
||||
Path: "",
|
||||
Name: ".",
|
||||
ParentID: "",
|
||||
ImageFiles: []string{"unrelated.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("includes top-level album folder for multi-disc albums", func() {
|
||||
// Album folder directly under library root, with disc subfolders
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Album",
|
||||
Name: "Disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Album",
|
||||
Name: "Disc2",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: ".",
|
||||
Name: "Album",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not query parent for single-folder albums that already have images", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("includes parent images for single-disc-subfolder albums", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("promotes the album root parent into the returned paths", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "artistFolder",
|
||||
Path: ".",
|
||||
Name: "Artist",
|
||||
ParentID: "libraryRoot",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
}
|
||||
|
||||
paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"}))
|
||||
})
|
||||
|
||||
It("does not include parent images when other albums' audio lives under the parent", func() {
|
||||
// Simulates: Artist/folder.jpg with Artist/Album (no images) and
|
||||
// another album's tracks elsewhere under the artist folder
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "artistFolder",
|
||||
Path: ".",
|
||||
Name: "Artist",
|
||||
ParentID: "libraryRoot",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
}
|
||||
repo.hasOtherAudio = true
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("propagates errors from the album-root check", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
repo.otherAudioErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.getErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("continues gracefully when parent folder is not found", func() {
|
||||
// Parent folder may have been deleted; should log a warning and continue
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
// parentResult is nil, so Get will return ErrNotFound
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxArtistFolderTraversalDepth defines how many directory levels to search
|
||||
// when looking for artist images (artist folder + parent directories)
|
||||
maxArtistFolderTraversalDepth = 3
|
||||
)
|
||||
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only consider albums where the artist is the sole album artist.
|
||||
als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artID.ID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lib libraryView
|
||||
if len(als) > 0 {
|
||||
lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
a := &artistReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
artist: *ar,
|
||||
artistFolder: artistFolder,
|
||||
imgFiles: imgFiles,
|
||||
lib: lib,
|
||||
}
|
||||
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
||||
// change _after_ retrieving from external sources, making the key invalid
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *ar.UpdatedAt
|
||||
}
|
||||
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
||||
}
|
||||
if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
|
||||
a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
|
||||
if a.imgFolderImgPath != "" {
|
||||
if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents))
|
||||
return fmt.Sprintf(
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
conf.Server.EnableExternalServices,
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *artistReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
ff := []sourceFunc{a.fromArtistUploadedImage()}
|
||||
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.artist.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case pattern == "image-folder":
|
||||
ff = append(ff, a.fromArtistImageFolder(ctx))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
if a.lib.FS != nil {
|
||||
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
}
|
||||
default:
|
||||
ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
// fromArtistFolder walks up from artistFolder toward libPath looking for a
|
||||
// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
|
||||
// and the library root: once we reach libPath (or if artistFolder is outside
|
||||
// libPath), the walk stops. All reads go through libFS, which keeps artwork
|
||||
// resolution scoped to the configured library.
|
||||
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if libFS == nil {
|
||||
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
||||
}
|
||||
rel, err := filepath.Rel(libPath, artistFolder)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
||||
}
|
||||
// fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
|
||||
// return backslash separators on Windows.
|
||||
rel = filepath.ToSlash(rel)
|
||||
current := artistFolder
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
|
||||
if err == nil {
|
||||
return reader, hit, nil
|
||||
}
|
||||
if rel == "." {
|
||||
break // reached library root; don't traverse above it
|
||||
}
|
||||
rel = path.Dir(rel)
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInFolder globs libFS at relFolder for pattern and returns the first
|
||||
// matching image. absFolder is used only for the returned display path and log
|
||||
// messages so callers see absolute-looking paths consistent with the rest of
|
||||
// the artwork pipeline.
|
||||
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
|
||||
globPattern := pattern
|
||||
if relFolder != "." {
|
||||
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
|
||||
}
|
||||
matches, err := fs.Glob(libFS, globPattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Filter to valid image files
|
||||
var imagePaths []string
|
||||
for _, m := range matches {
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
imagePaths = append(imagePaths, m)
|
||||
}
|
||||
|
||||
// Sort image files by prioritizing base filenames without numeric
|
||||
// suffixes (e.g., artist.jpg before artist.1.jpg)
|
||||
slices.SortFunc(imagePaths, compareImageFiles)
|
||||
|
||||
for _, p := range imagePaths {
|
||||
f, err := libFS.Open(p)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", p, err)
|
||||
continue
|
||||
}
|
||||
_, name := path.Split(p)
|
||||
return f, filepath.Join(absFolder, name), nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
|
||||
}
|
||||
|
||||
func escapeGlobLiteral(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '*', '?', '[', ']':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// loadArtistAlbumRoots returns one path per album — the deepest folder holding
|
||||
// all of that album's tracks — so an album split into disc subfolders can't
|
||||
// pull the artist folder's common prefix below the artist level.
|
||||
func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) {
|
||||
var folderIDs []string
|
||||
for _, album := range albums {
|
||||
folderIDs = append(folderIDs, album.FolderIDs...)
|
||||
}
|
||||
folders, err := loadFolders(ctx, ds, folderIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) {
|
||||
return f.ID, f.AbsolutePath()
|
||||
})
|
||||
var roots []string
|
||||
for _, album := range albums {
|
||||
var albumPaths []string
|
||||
for _, fid := range album.FolderIDs {
|
||||
if p, ok := pathByID[fid]; ok {
|
||||
albumPaths = append(albumPaths, p)
|
||||
}
|
||||
}
|
||||
if len(albumPaths) > 0 {
|
||||
roots = append(roots, commonDir(albumPaths))
|
||||
}
|
||||
}
|
||||
|
||||
imgFiles, updatedAt := folderImages(folders)
|
||||
return roots, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// commonDir returns the deepest directory containing all paths. Trailing
|
||||
// separators keep the comparison on segment boundaries, so a shared name
|
||||
// fragment (".../Album" and ".../Album2") is never read as a shared directory.
|
||||
func commonDir(paths []string) string {
|
||||
sep := string(filepath.Separator)
|
||||
common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep }))
|
||||
if !strings.HasSuffix(common, sep) {
|
||||
common, _ = filepath.Split(common)
|
||||
}
|
||||
return filepath.Clean(common)
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
if len(albums) == 0 {
|
||||
return "", time.Time{}, nil
|
||||
}
|
||||
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
|
||||
|
||||
// paths holds one root per album: two or more distinct roots already meet at
|
||||
// the artist folder, while a single root is an album folder needing a climb.
|
||||
roots := slices.Compact(slices.Sorted(slices.Values(paths)))
|
||||
folderPath := commonDir(roots)
|
||||
if len(roots) < 2 {
|
||||
folderPath = filepath.Dir(folderPath)
|
||||
}
|
||||
|
||||
// Manipulate the path to get the folder ID
|
||||
// TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
// Get the last update time for the folder
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
// Use cached path from newArtistArtworkReader if available,
|
||||
// avoiding a second directory scan.
|
||||
path := a.imgFolderImgPath
|
||||
if path == "" {
|
||||
path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
|
||||
}
|
||||
if path == "" {
|
||||
return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
|
||||
// (case-insensitive). Returns the full path, or empty string if not found.
|
||||
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||
return filepath.Join(folder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,761 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
fds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
albums model.Albums
|
||||
paths []string
|
||||
now time.Time
|
||||
expectedUpdTime time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedUpdTime = now.Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{
|
||||
result: []model.Folder{
|
||||
{
|
||||
ImagesUpdatedAt: expectedUpdTime,
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
fds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
albums = model.Albums{
|
||||
{LibraryID: 1, ID: "album1", Name: "Album 1"},
|
||||
}
|
||||
})
|
||||
|
||||
When("no albums provided", func() {
|
||||
It("returns empty and zero time", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
When("artist has only one album", func() {
|
||||
It("returns the parent folder", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("the artist have multiple albums", func() {
|
||||
It("returns the common prefix for the albums paths", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/library/artist/one"),
|
||||
filepath.FromSlash("/music/library/artist/two"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("two albums share the same folder", func() {
|
||||
It("climbs above the shared album folder", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("the album paths contain same prefix", func() {
|
||||
It("returns the common prefix", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("ds.Folder().GetAll returns an error", func() {
|
||||
It("returns an error", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
}
|
||||
repo.err = errors.New("fake error")
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).To(MatchError(ContainSubstring("fake error")))
|
||||
// Folder and time are empty on error.
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
libFS fs.FS
|
||||
testFunc sourceFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tempDir = GinkgoT().TempDir()
|
||||
libFS = os.DirFS(tempDir)
|
||||
})
|
||||
|
||||
When("artist folder contains matching image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/artist/artist.jpg
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds and returns the image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
// Verify we can read the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder name contains glob metacharacters", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "Artist [Live]")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("treats the folder path literally when globbing through the library fs", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("bracketed artist image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder is empty but parent contains image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
|
||||
parentDir := filepath.Join(tempDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
albumDir := filepath.Join(artistDir, "album")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in parent directory
|
||||
artistImagePath := filepath.Join(parentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in parent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("parent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("image is two levels up", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in grandparent directory
|
||||
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in grandparent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("grandparent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("images exist at multiple levels", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure with images at multiple levels
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist images at all levels
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("prioritizes the closest (artist folder) image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist level"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("pattern matches multiple files", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create multiple matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns the first valid image file in sorted order", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
|
||||
// Should return an image file,
|
||||
// Files are sorted: jpg comes before png alphabetically.
|
||||
// .abc comes first, but it's not an image.
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("prioritizing files without numeric suffixes", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case for issue #4683: artist.jpg should come before artist.1.jpg
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create multiple matches with and without numeric suffixes
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
// Verify it's the main file, not a numbered variant
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist main"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("handling case-insensitive sorting", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case to ensure case-insensitive natural sorting
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create files with mixed case names
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*")
|
||||
})
|
||||
|
||||
It("sorts case-insensitively", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
|
||||
// Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching files exist anywhere", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create non-matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns an error", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
|
||||
Expect(err.Error()).To(ContainSubstring("parent directories"))
|
||||
})
|
||||
})
|
||||
|
||||
When("directory traversal reaches filesystem root", func() {
|
||||
BeforeEach(func() {
|
||||
// Start from a shallow directory to test root boundary
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("handles root boundary gracefully", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
// Should not panic or cause infinite loop
|
||||
})
|
||||
})
|
||||
|
||||
When("file exists but cannot be opened", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create a file that cannot be opened (permission denied)
|
||||
restrictedFile := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("logs warning and continues searching", func() {
|
||||
// This test depends on the ability to restrict file permissions
|
||||
// For now, we'll just ensure it doesn't panic and returns appropriate error
|
||||
reader, _, err := testFunc()
|
||||
// The file should be readable in test environment, so this will succeed
|
||||
// In a real scenario with permission issues, it would continue searching
|
||||
if err == nil {
|
||||
Expect(reader).ToNot(BeNil())
|
||||
reader.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
When("single album artist scenario (original issue)", func() {
|
||||
BeforeEach(func() {
|
||||
// Simulate the exact folder structure from the issue:
|
||||
// /music/artist/album1/ (single album)
|
||||
// /music/artist/artist.jpg (artist image that should be found)
|
||||
artistDir := filepath.Join(tempDir, "music", "artist")
|
||||
albumDir := filepath.Join(artistDir, "album1")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Create artist.jpg in the artist folder (this was not being found before)
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
|
||||
|
||||
// The fromArtistFolder is called with the artist folder path
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds artist.jpg in artist folder for single album artist", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
Expect(path).To(ContainSubstring("artist"))
|
||||
|
||||
// Verify the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("single album artist image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistUploadedImage", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
|
||||
// Create the artwork/artist directory
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
|
||||
reader = &artistReader{}
|
||||
})
|
||||
|
||||
When("artist has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||
|
||||
reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("uploaded artist image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.artist = model.Artist{ID: "ar-1"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistImageFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
ar *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
ar = &artistReader{}
|
||||
})
|
||||
|
||||
When("ArtistImageFolder is not configured", func() {
|
||||
It("returns nil (skips)", func() {
|
||||
conf.Server.ArtistImageFolder = ""
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("image exists matching MBID", func() {
|
||||
It("finds the image by MBID", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("MBID match is case-insensitive", func() {
|
||||
It("finds the image regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
|
||||
imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no MBID file exists but artist name file does", func() {
|
||||
It("falls back to artist name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("name image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist name match is case-insensitive", func() {
|
||||
It("matches regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "test artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("both MBID and name files exist", func() {
|
||||
It("prefers MBID over name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
mbidPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
namePath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(mbidPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching image found", func() {
|
||||
It("returns an error", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
// Create an unrelated file
|
||||
Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, _, err := sf()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err.Error()).To(ContainSubstring("no image found"))
|
||||
})
|
||||
})
|
||||
|
||||
When("cached imgFolderImgPath is set", func() {
|
||||
It("uses cached path instead of scanning", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "cached.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
ar.imgFolderImgPath = imgPath
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("cached image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("findImageInArtistFolder", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
When("matching file exists by MBID", func() {
|
||||
It("returns the file path", func() {
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, mbid, "Test")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("matching file exists by name", func() {
|
||||
It("returns the file path", func() {
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, "", "Test Artist")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching file exists", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("folder does not exist", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder("/nonexistent/path", "", "Test")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
parentResult *model.Folder
|
||||
getErr error
|
||||
getCallCount int
|
||||
err error
|
||||
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root
|
||||
// check). False means the parent qualifies as an album root.
|
||||
hasOtherAudio bool
|
||||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
if f.parentResult != nil {
|
||||
return f.parentResult, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeDataStore struct {
|
||||
model.DataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
}
|
||||
|
||||
func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository {
|
||||
return fds.folderRepo
|
||||
}
|
||||
|
||||
func stubCoreAbsolutePath() func() {
|
||||
// Override core.AbsolutePath to return a fixed string during tests.
|
||||
original := core.AbsolutePath
|
||||
core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string {
|
||||
return filepath.FromSlash("/music")
|
||||
}
|
||||
return func() {
|
||||
core.AbsolutePath = original
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type mediafileArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
mediafile model.MediaFile
|
||||
album model.Album
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
|
||||
mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
al, err := artwork.ds.Album(ctx).Get(mf.AlbumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &mediafileArtworkReader{
|
||||
a: artwork,
|
||||
mediafile: *mf,
|
||||
album: *al,
|
||||
lib: lib,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *mediafileArtworkReader) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%t",
|
||||
a.cacheKey.Key(),
|
||||
conf.Server.EnableMediaFileCoverArt,
|
||||
)
|
||||
}
|
||||
func (a *mediafileArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff []sourceFunc
|
||||
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
|
||||
ff = []sourceFunc{
|
||||
fromTag(ctx, a.lib.FS, a.mediafile.Path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
|
||||
}
|
||||
}
|
||||
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
|
||||
// skip disc resolution (it would just fall through to album art anyway).
|
||||
if len(a.album.Discs) > 1 {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
|
||||
} else {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type playlistArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
pl model.Playlist
|
||||
}
|
||||
|
||||
const tileSize = 600
|
||||
|
||||
func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) {
|
||||
pl, err := artwork.ds.Playlist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &playlistArtworkReader{
|
||||
a: artwork,
|
||||
pl: *pl,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||
|
||||
// Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
|
||||
// If either is newer than the playlist's UpdatedAt, use that instead so the
|
||||
// cache is busted when a user replaces a sidecar image or local file reference.
|
||||
for _, path := range []string{
|
||||
findPlaylistSidecarPath(ctx, pl.Path),
|
||||
pl.ExternalImageURL,
|
||||
} {
|
||||
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromPlaylistUploadedImage(),
|
||||
a.fromPlaylistSidecar(ctx),
|
||||
a.fromPlaylistExternalImage(ctx),
|
||||
a.fromGeneratedTiledCover(ctx),
|
||||
fromAlbumPlaceholder(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.pl.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc {
|
||||
return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path))
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imgURL := a.pl.ExternalImageURL
|
||||
if imgURL == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
parsed, err := url.Parse(imgURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if parsed.Scheme == "http" || parsed.Scheme == "https" {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return nil, "", nil
|
||||
}
|
||||
return fromURL(ctx, parsed)
|
||||
}
|
||||
return fromLocalFile(imgURL)()
|
||||
}
|
||||
}
|
||||
|
||||
// fromLocalFile returns a sourceFunc that opens the given local path.
|
||||
// Returns (nil, "", nil) if path is empty — signalling "not found, try next source".
|
||||
func fromLocalFile(path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
|
||||
// image file with the same base name (case-insensitive). Returns empty string if
|
||||
// no matching image is found or if plsPath is empty.
|
||||
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
|
||||
if plsPath == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(plsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
nameBase := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
tiles, err := a.loadTiles(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
r, err := a.createTiledImage(ctx, tiles)
|
||||
return r, "", err
|
||||
}
|
||||
}
|
||||
|
||||
func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID {
|
||||
return slice.Map(albumIDs, func(id string) model.ArtworkID {
|
||||
al := model.Album{ID: id}
|
||||
return al.CoverArtID()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) {
|
||||
tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false)
|
||||
albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
ids := toAlbumArtworkIDs(albumIds)
|
||||
|
||||
var tiles []image.Image
|
||||
for _, id := range ids {
|
||||
r, _, err := fromAlbum(ctx, a.a, id)()
|
||||
if err == nil {
|
||||
tile, err := a.createTile(ctx, r)
|
||||
if err == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
switch len(tiles) {
|
||||
case 0:
|
||||
return nil, errors.New("could not find any eligible cover")
|
||||
case 2:
|
||||
tiles = append(tiles, tiles[1], tiles[0])
|
||||
case 3:
|
||||
tiles = append(tiles, tiles[0])
|
||||
}
|
||||
return tiles, nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) {
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
var rgba draw.Image
|
||||
var err error
|
||||
if len(tiles) == 4 {
|
||||
rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||
err = png.Encode(buf, rgba)
|
||||
} else {
|
||||
err = png.Encode(buf, tiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
|
||||
func rect(pos int) image.Rectangle {
|
||||
r := image.Rectangle{}
|
||||
switch pos {
|
||||
case 1:
|
||||
r.Min.X = tileSize / 2
|
||||
case 2:
|
||||
r.Min.Y = tileSize / 2
|
||||
case 3:
|
||||
r.Min.X = tileSize / 2
|
||||
r.Min.Y = tileSize / 2
|
||||
}
|
||||
r.Max.X = r.Min.X + tileSize/2
|
||||
r.Max.Y = r.Min.Y + tileSize/2
|
||||
return r
|
||||
}
|
||||
|
||||
// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly,
|
||||
// equivalent to imaging.Fill with Center anchor.
|
||||
func fillCenter(src image.Image, dstW, dstH int) image.Image {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
// Calculate crop rectangle (center crop to match destination aspect ratio)
|
||||
srcAspect := float64(srcW) / float64(srcH)
|
||||
dstAspect := float64(dstW) / float64(dstH)
|
||||
|
||||
var cropRect image.Rectangle
|
||||
if srcAspect > dstAspect {
|
||||
// Source is wider — crop horizontally
|
||||
cropW := int(float64(srcH) * dstAspect)
|
||||
cropX := (srcW - cropW) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
|
||||
} else {
|
||||
// Source is taller — crop vertically
|
||||
cropH := int(float64(srcW) / dstAspect)
|
||||
cropY := (srcH - cropH) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
|
||||
}
|
||||
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type radioArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
radio model.Radio
|
||||
}
|
||||
|
||||
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
|
||||
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &radioArtworkReader{a: artwork, radio: *r}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = r.UpdatedAt
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromRadioUploadedImage(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.radio.UploadedImagePath())
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("radioArtworkReader", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *radioArtworkReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
|
||||
reader = &radioArtworkReader{}
|
||||
})
|
||||
|
||||
Describe("fromRadioUploadedImage", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Reader", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the image reader", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns ErrUnavailable", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
|
||||
// package init() and exposes the result only via webp.Dynamic(); there is
|
||||
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
|
||||
// crashes (issue #5597), so those builds must be compiled with the
|
||||
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
|
||||
// error here and forces the safe WASM path.
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
artID model.ArtworkID
|
||||
cacheKey string
|
||||
lastUpdate time.Time
|
||||
size int
|
||||
square bool
|
||||
a *artwork
|
||||
}
|
||||
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
|
||||
r := &resizedArtworkReader{a: a}
|
||||
r.artID = artID
|
||||
r.size = size
|
||||
r.square = square
|
||||
|
||||
// Get lastUpdated and cacheKey from original artwork
|
||||
original, err := a.getArtworkReader(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.cacheKey = original.Key()
|
||||
r.lastUpdate = original.LastUpdated()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Key() string {
|
||||
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := a.resizeImage(ctx, orig)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
} else {
|
||||
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err)
|
||||
}
|
||||
if err != nil || resized == nil {
|
||||
// if we couldn't resize the image, return the original
|
||||
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
|
||||
return orig, "", err
|
||||
}
|
||||
// Preserve ReadCloser semantics if the resized reader already supports Close
|
||||
// (e.g., ffmpeg pipe), otherwise wrap with NopCloser
|
||||
if rc, ok := resized.(io.ReadCloser); ok {
|
||||
return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
||||
}
|
||||
|
||||
// Preserve animation for animated images
|
||||
if isAnimatedGIF(data) {
|
||||
if a.a.ffmpeg.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
}
|
||||
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
// toFastScaleType converts images whose concrete type has no optimized scaler
|
||||
// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
|
||||
// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
|
||||
// falls back to a generic per-pixel At()/RGBA() loop that is several times
|
||||
// slower. Fast-path types are returned unchanged.
|
||||
func toFastScaleType(img image.Image) image.Image {
|
||||
switch img.(type) {
|
||||
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
|
||||
return img
|
||||
default:
|
||||
rgba := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
||||
return rgba
|
||||
}
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
|
||||
// Clamp size to original dimensions - upscaling wastes resources and adds no information
|
||||
if size > originalSize {
|
||||
size = originalSize
|
||||
}
|
||||
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
// Calculate aspect-fit dimensions
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
scale := float64(size) / float64(max(srcW, srcH))
|
||||
dstW := int(float64(srcW) * scale)
|
||||
dstH := int(float64(srcH) * scale)
|
||||
|
||||
var dst *image.NRGBA
|
||||
var dstRect image.Rectangle
|
||||
if square {
|
||||
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
offsetX := (size - dstW) / 2
|
||||
offsetY := (size - dstH) / 2
|
||||
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
|
||||
} else {
|
||||
// Tight-fit canvas
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
original = toFastScaleType(original)
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
return nil, originalSize, err
|
||||
}
|
||||
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
|
||||
encoded := make([]byte, buf.Len())
|
||||
copy(encoded, buf.Bytes())
|
||||
bufPool.Put(buf)
|
||||
return bytes.NewReader(encoded), originalSize, nil
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resizeImage", func() {
|
||||
var mockFF *tests.MockFFmpeg
|
||||
var r *resizedArtworkReader
|
||||
|
||||
BeforeEach(func() {
|
||||
mockFF = tests.NewMockFFmpeg("converted-animated-data")
|
||||
r = &resizedArtworkReader{
|
||||
size: 300,
|
||||
square: false,
|
||||
a: &artwork{ffmpeg: mockFF},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("animated GIF handling", func() {
|
||||
It("converts animated GIF via ffmpeg when available", func() {
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns "converted-animated-data")
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data)) // MockFFmpeg echoes input back
|
||||
})
|
||||
|
||||
It("falls back to static resize when ffmpeg fails for animated GIF", func() {
|
||||
mockFF.Error = errors.New("ffmpeg failed")
|
||||
// Use size smaller than image so static resize actually produces output
|
||||
r.size = 1
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Should fall through to static resize successfully (no ffmpeg error propagated)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Verify it's a static image (WebP encoded), not the ffmpeg error
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(output)).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("preserves animation for square thumbnails with animated GIF", func() {
|
||||
r.square = true
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns input data)
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated WebP handling", func() {
|
||||
It("returns animated WebP data as-is when not square", func() {
|
||||
data := createAnimatedWebPBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("preserves animated WebP for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAnimatedWebPBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated PNG handling", func() {
|
||||
It("returns animated PNG data as-is when not square", func() {
|
||||
data := createAPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("preserves animated PNG for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("static image handling", func() {
|
||||
It("resizes a static PNG normally", func() {
|
||||
data := createStaticPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static PNG is 2x2, size 300 is larger, so should return nil (no upscale)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadCloser preservation", func() {
|
||||
It("preserves Close semantics from ffmpeg ReadCloser", func() {
|
||||
// Create a trackable ReadCloser
|
||||
tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))}
|
||||
mockFF2 := &mockFFmpegWithCloser{tracker: tracker}
|
||||
r.a = &artwork{ffmpeg: mockFF2}
|
||||
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The result should be an io.ReadCloser (the tracker)
|
||||
rc, ok := result.(io.ReadCloser)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(rc.Close()).ToNot(HaveOccurred())
|
||||
Expect(tracker.closed).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// closeTracker is an io.ReadCloser that tracks whether Close was called.
|
||||
type closeTracker struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *closeTracker) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser
|
||||
// for ConvertAnimatedImage, allowing us to verify Close propagation.
|
||||
type mockFFmpegWithCloser struct {
|
||||
ffmpeg.FFmpeg
|
||||
tracker *closeTracker
|
||||
}
|
||||
|
||||
func (m *mockFFmpegWithCloser) IsAvailable() bool { return true }
|
||||
func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) {
|
||||
return m.tracker, nil
|
||||
}
|
||||
144
core/artwork/resize.go
Normal file
144
core/artwork/resize.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
// gen2brain/webp picks native vs WASM in its own init(), with no way to switch at
|
||||
// runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM.
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Artwork: Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
// resizeImageData resizes raw image bytes to fit size, preserving animation where
|
||||
// possible. A nil reader means the image was already within bounds (no resize needed).
|
||||
func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square,
|
||||
"elapsed", time.Since(start))
|
||||
}()
|
||||
if isAnimatedGIF(data) {
|
||||
if ffm.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err)
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
}
|
||||
|
||||
return resizeStaticImage(data, size, square)
|
||||
}
|
||||
|
||||
// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA,
|
||||
// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback.
|
||||
func toFastScaleType(img image.Image) image.Image {
|
||||
switch img.(type) {
|
||||
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
|
||||
return img
|
||||
default:
|
||||
rgba := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
||||
return rgba
|
||||
}
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
|
||||
// Clamp size to original dimensions - upscaling wastes resources and adds no information
|
||||
if size > originalSize {
|
||||
size = originalSize
|
||||
}
|
||||
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
// Calculate aspect-fit dimensions
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
scale := float64(size) / float64(max(srcW, srcH))
|
||||
dstW := int(float64(srcW) * scale)
|
||||
dstH := int(float64(srcH) * scale)
|
||||
|
||||
var dst *image.NRGBA
|
||||
var dstRect image.Rectangle
|
||||
if square {
|
||||
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
offsetX := (size - dstW) / 2
|
||||
offsetY := (size - dstH) / 2
|
||||
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
|
||||
} else {
|
||||
// Tight-fit canvas
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
original = toFastScaleType(original)
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
return nil, originalSize, err
|
||||
}
|
||||
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
|
||||
encoded := bytes.Clone(buf.Bytes())
|
||||
bufPool.Put(buf)
|
||||
return bytes.NewReader(encoded), originalSize, nil
|
||||
}
|
||||
|
||||
// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key
|
||||
// fragment, so flipping either setting invalidates previously-encoded sized artwork.
|
||||
func formatQualityTag() string {
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality)
|
||||
}
|
||||
return fmt.Sprintf("q%d", conf.Server.CoverArtQuality)
|
||||
}
|
||||
452
core/artwork/resolve.go
Normal file
452
core/artwork/resolve.go
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// resolution is one attempted acquisition outcome for an entity.
|
||||
type resolution struct {
|
||||
reader io.ReadCloser // nil when no source yielded an image
|
||||
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
|
||||
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
|
||||
refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath
|
||||
// external source errored/timed out. With no reader it forces failed (never absent);
|
||||
// on a hit a higher-priority external step failed—serve this, but retry later.
|
||||
extError bool
|
||||
// a local source that should have been readable wasn't. With no reader it forces failed,
|
||||
// so a transient I/O fault never records absent.
|
||||
localError bool
|
||||
}
|
||||
|
||||
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
|
||||
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
|
||||
type chainState struct{ extErr, localErr bool }
|
||||
|
||||
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
|
||||
func (c *chainState) try(res resolution, ok bool) (resolution, bool) {
|
||||
if ok {
|
||||
res.extError = c.extErr
|
||||
return res, true
|
||||
}
|
||||
c.localErr = c.localErr || res.localError
|
||||
return resolution{}, false
|
||||
}
|
||||
|
||||
// exhausted is the outcome when no source in the chain yielded an image.
|
||||
func (c *chainState) exhausted() resolution {
|
||||
return resolution{extError: c.extErr, localError: c.localErr}
|
||||
}
|
||||
|
||||
// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through.
|
||||
type externalSource struct {
|
||||
agents *agents.Agents
|
||||
gate gateFunc
|
||||
}
|
||||
|
||||
// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only.
|
||||
type resolver struct {
|
||||
ds model.DataStore
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
ext *externalSource
|
||||
}
|
||||
|
||||
func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate gateFunc) *resolver {
|
||||
if gate == nil {
|
||||
gate = passthroughGate
|
||||
}
|
||||
return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}}
|
||||
}
|
||||
|
||||
// newLocalResolver builds a resolver that can neither reach the network nor sample album art
|
||||
// for the worker-built grid.
|
||||
func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver {
|
||||
return &resolver{ds: ds, ffmpeg: ffm}
|
||||
}
|
||||
|
||||
func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (resolution, error) {
|
||||
kind, _ := model.ParseKind(item.ItemKind)
|
||||
switch kind {
|
||||
case model.KindAlbumArtwork:
|
||||
return r.resolveAlbum(ctx, item.ItemID)
|
||||
case model.KindArtistArtwork:
|
||||
return r.resolveArtist(ctx, item.ItemID)
|
||||
case model.KindPlaylistArtwork:
|
||||
return r.resolvePlaylist(ctx, item.ItemID)
|
||||
case model.KindRadioArtwork:
|
||||
return r.resolveRadio(ctx, item.ItemID)
|
||||
case model.KindMediaFileArtwork:
|
||||
return r.resolveMediaFile(ctx, item.ItemID)
|
||||
default:
|
||||
return resolution{}, fmt.Errorf("artwork: kind %q is not resolvable by the worker", item.ItemKind)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network,
|
||||
// so a local-only resolver is stopped here rather than at each point in the chain walk.
|
||||
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) {
|
||||
if r.ext == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al)
|
||||
}
|
||||
|
||||
func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) {
|
||||
if r.ext == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar)
|
||||
}
|
||||
|
||||
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
|
||||
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
||||
al, err := r.ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, r.ds, *al)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, r.ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var chain chainState
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok {
|
||||
return res, nil
|
||||
}
|
||||
case pattern == "external":
|
||||
if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil {
|
||||
return resolution{reader: rd, source: "external:" + name}, nil
|
||||
} else if isErr {
|
||||
chain.extErr = true
|
||||
}
|
||||
case len(imgFiles) > 0:
|
||||
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
||||
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
||||
ar, err := r.ds.Artist(ctx).Get(artistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload")
|
||||
if ok {
|
||||
return upload, nil
|
||||
}
|
||||
if upload.localError {
|
||||
// The upload outranks every other source; falling through would persist a lower-priority
|
||||
// image as if the upload were gone.
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
// Only consider albums where the artist is the sole album artist.
|
||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
albumPaths, imgFiles, _, err := loadArtistAlbumRoots(ctx, r.ds, als)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
artistFolder, _, err := loadArtistFolder(ctx, r.ds, als, albumPaths)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
var lib libraryView
|
||||
if len(als) > 0 {
|
||||
lib, err = loadLibraryView(ctx, r.ds, als[0].LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
}
|
||||
|
||||
var chain chainState
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil {
|
||||
return resolution{reader: rd, source: "external:" + name}, nil
|
||||
} else if isErr {
|
||||
chain.extErr = true
|
||||
}
|
||||
case pattern == "image-folder":
|
||||
if res, ok := chain.try(resolveArtistImageFolder(ar)); ok {
|
||||
return res, nil
|
||||
}
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
if lib.FS == nil {
|
||||
continue
|
||||
}
|
||||
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok {
|
||||
return res, nil
|
||||
}
|
||||
default:
|
||||
if lib.FS == nil || artistFolder == "" {
|
||||
continue
|
||||
}
|
||||
if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
for _, src := range []struct{ path, source string }{
|
||||
{pl.UploadedImagePath(), "upload"},
|
||||
{findPlaylistSidecarPath(ctx, pl.Path), "folder"},
|
||||
} {
|
||||
res, ok := resolveLocalFile(src.path, src.source)
|
||||
if ok {
|
||||
return res, nil
|
||||
}
|
||||
if res.localError {
|
||||
// These outrank the generated grid; falling through would replace them with it.
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
// A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch.
|
||||
localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL)
|
||||
if localImg != "" {
|
||||
res, ok := resolveLocalFile(localImg, "folder")
|
||||
if ok {
|
||||
return res, nil
|
||||
}
|
||||
if res.localError {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
if r.ext == nil {
|
||||
// The remote fetch and the generated grid are worker-only; a request must do neither.
|
||||
return resolution{}, nil
|
||||
}
|
||||
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
|
||||
sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) }
|
||||
if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
extErr = true
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var tiles []image.Image
|
||||
var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight
|
||||
for _, albumID := range albumIDs {
|
||||
res, err := r.resolveAlbum(ctx, albumID)
|
||||
if err != nil {
|
||||
if tileErr == nil {
|
||||
tileErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.extError {
|
||||
extErr = true
|
||||
}
|
||||
if res.reader == nil {
|
||||
continue
|
||||
}
|
||||
tile, decErr := decodeTile(res.reader)
|
||||
res.reader.Close()
|
||||
if decErr == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
// A tile-level failure must never resolve as a clean absent.
|
||||
if tileErr != nil {
|
||||
return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr)
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
// Grow to 4 tiles by repeating what we have.
|
||||
switch len(tiles) {
|
||||
case 2:
|
||||
tiles = append(tiles, tiles[1], tiles[0])
|
||||
case 3:
|
||||
tiles = append(tiles, tiles[0])
|
||||
}
|
||||
grid, err := assembleTiles(tiles)
|
||||
if err != nil {
|
||||
return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error
|
||||
}
|
||||
return resolution{reader: grid, source: "generated", extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
||||
radio, err := r.ds.Radio(ctx).Get(radioID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
res, _ := resolveLocalFile(radio.UploadedImagePath(), "upload")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
||||
// is a definitive absent.
|
||||
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
||||
mf, err := r.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt {
|
||||
return resolution{}, nil
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
|
||||
// a not-found, which is a definitive "no" rather than a failure.
|
||||
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
|
||||
r, path, err := gate(name, sf)
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "external", sourcePath: path}, true, false
|
||||
}
|
||||
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
|
||||
}
|
||||
|
||||
// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a
|
||||
// remote http(s) URL; at most one is set.
|
||||
func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) {
|
||||
if imageURL == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(imageURL)
|
||||
if err != nil {
|
||||
return imageURL, nil // unparseable → treat as a local path
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
return "", u
|
||||
case "file":
|
||||
return u.Path, nil
|
||||
default:
|
||||
return imageURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) {
|
||||
if embedRel == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
abs := lib.Abs(embedRel)
|
||||
var unreadable bool
|
||||
for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} {
|
||||
r, _, err := sf()
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true
|
||||
}
|
||||
unreadable = unreadable || errors.Is(err, errSourceUnreadable)
|
||||
}
|
||||
return resolution{localError: unreadable}, false
|
||||
}
|
||||
|
||||
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
||||
r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
|
||||
if r == nil {
|
||||
return resolution{localError: errors.Is(err, errSourceUnreadable)}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
|
||||
}
|
||||
|
||||
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder")
|
||||
}
|
||||
|
||||
func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) {
|
||||
r, path, err := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)()
|
||||
if r == nil {
|
||||
return resolution{localError: errors.Is(err, errSourceUnreadable)}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
|
||||
// open failure says nothing about whether the image exists.
|
||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
if path == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false
|
||||
}
|
||||
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
func mtimeOf(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().UnixNano()
|
||||
}
|
||||
|
||||
// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths.
|
||||
func mtimeViaFS(fsys fs.FS, name string) int64 {
|
||||
if fsys == nil || name == "" {
|
||||
return 0
|
||||
}
|
||||
info, err := fs.Stat(fsys, name)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().UnixNano()
|
||||
}
|
||||
632
core/artwork/resolve_test.go
Normal file
632
core/artwork/resolve_test.go
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resolveItem", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
ag *agents.Agents
|
||||
repoRoot string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
})
|
||||
|
||||
Describe("kind dispatch", func() {
|
||||
It("returns an error for kinds the worker never enqueues", func() {
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("media file", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
|
||||
})
|
||||
|
||||
It("resolves embedded art from the track file", func() {
|
||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("resolves absent when the track has no cover art", func() {
|
||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("resolves absent when media file cover art is disabled", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the error when the track is not in the DB", func() {
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"})
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(res.reader).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("album", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
})
|
||||
|
||||
It("resolves folder art from the library FS", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("falls back to embedded art when no folder image matches", func() {
|
||||
folderRepo.result = nil
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("sets extError when the external source errors without being not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al3", Name: "Album"},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not set extError when the external source reports not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al4", Name: "Album"},
|
||||
})
|
||||
// no image agents enabled -> the external step is a definitive not-found
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("carries extError onto a fallback folder hit after a transient external failure", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al6", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
// no image agents enabled -> the external step is a definitive not-found
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("routes the external step through the injected gate, keyed by agent name", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al5", Name: "Album"},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
||||
var gatedNames []string
|
||||
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return f()
|
||||
}
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(gatedNames).To(Equal([]string{"failAgent"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artist", func() {
|
||||
It("resolves the uploaded image before any priority chain lookup", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("falls through to the ArtistArtPriority chain when there is no upload", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||
folderRepo.result = []model.Folder{{
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{
|
||||
{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}},
|
||||
}
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
|
||||
})
|
||||
|
||||
It("sets extError when the external source errors without being not-found", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not set extError when the external source reports not-found", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
// no image agents enabled -> the external step is a definitive not-found
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("routes the external step through the injected gate, keyed by agent name", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
||||
var gatedNames []string
|
||||
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return f()
|
||||
}
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(gatedNames).To(Equal([]string{"failAgent"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("radio", func() {
|
||||
It("yields an empty resolution when there is no uploaded image", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(Equal(resolution{}))
|
||||
})
|
||||
|
||||
It("resolves the uploaded image when set", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("playlist", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "t1", Name: "T1", FolderIDs: []string{"f1"}},
|
||||
{ID: "t2", Name: "T2", FolderIDs: []string{"f1"}},
|
||||
{ID: "t3", Name: "T3", FolderIDs: []string{"f1"}},
|
||||
{ID: "t4", Name: "T4", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("yields a generated grid from up to 4 album tiles",
|
||||
func(albumIDs []string, expectedSize int) {
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("generated"))
|
||||
|
||||
img, format, err := image.Decode(res.reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Dx()).To(Equal(expectedSize))
|
||||
Expect(img.Bounds().Dy()).To(Equal(expectedSize))
|
||||
},
|
||||
// tileSize-1: the 4-tile canvas is built as [0, tileSize-1].
|
||||
Entry("1 album -> single tile", []string{"t1"}, tileSize/2),
|
||||
Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1),
|
||||
Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1),
|
||||
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
|
||||
)
|
||||
|
||||
It("resolves the uploaded image before the generated grid", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("resolves a sidecar image next to the playlist file before the grid", func() {
|
||||
plDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg"))
|
||||
})
|
||||
|
||||
It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
var gatedNames []string
|
||||
gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return nil, "", errors.New("network down")
|
||||
}
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
|
||||
})
|
||||
|
||||
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
|
||||
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("generated"))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
// A local resolver holds no agents: reaching the external branch would panic, not degrade.
|
||||
It("skips the external step instead of dereferencing absent agents", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||
|
||||
res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one")
|
||||
})
|
||||
|
||||
// The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
|
||||
It("resolves a playlist locally without fetching remotely or building the grid", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"}
|
||||
|
||||
res, err := newLocalResolver(ds, ffm).resolve(ctx, item)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only")
|
||||
Expect(hits.Load()).To(BeZero(), "a request must never reach the network")
|
||||
|
||||
worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(worker.reader).ToNot(BeNil())
|
||||
defer worker.reader.Close()
|
||||
Expect(worker.source).To(Equal("generated"), "the worker does build the grid")
|
||||
Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch")
|
||||
})
|
||||
|
||||
It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("yields an empty resolution when no album has art", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "empty1", Name: "Empty"},
|
||||
})
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
folderRepo.result = nil
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.source).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("skips a grid tile whose declared dimensions are a decompression bomb", func() {
|
||||
libRoot := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}}
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.source).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not resolve as absent when every sampled album fails to resolve", func() {
|
||||
// The album ids are absent from MockAlbumRepo, so every tile fails non-externally.
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(res).To(Equal(resolution{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// decodeTile runs before the processor's own guards, so it must enforce the caps itself.
|
||||
var _ = Describe("decodeTile", func() {
|
||||
It("rejects a decompression bomb before the full decode", func() {
|
||||
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
|
||||
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
||||
})
|
||||
|
||||
It("rejects a tile larger than the size cap", func() {
|
||||
data := bytes.Repeat([]byte{0}, maxImageBytes+1)
|
||||
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ package artwork
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
|
@ -16,14 +17,16 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing
|
||||
// to open it is not evidence the entity has no artwork, so callers must not settle on absent.
|
||||
var errSourceUnreadable = errors.New("artwork source unreadable")
|
||||
|
||||
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
|
||||
for _, f := range extractFuncs {
|
||||
if ctx.Err() != nil {
|
||||
|
|
@ -32,11 +35,11 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
|
|||
start := time.Now()
|
||||
r, path, err := f()
|
||||
if r != nil {
|
||||
msg := fmt.Sprintf("Found %s artwork", artID.Kind)
|
||||
msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind)
|
||||
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
|
||||
return r, path, nil
|
||||
}
|
||||
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
||||
}
|
||||
|
|
@ -55,11 +58,12 @@ func (f sourceFunc) String() string {
|
|||
|
||||
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
var openErr error
|
||||
for _, file := range files {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
||||
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
|
|
@ -67,11 +71,15 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
|
|||
}
|
||||
f, err := libFS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", file, err)
|
||||
log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err)
|
||||
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
if openErr != nil {
|
||||
return nil, "", openErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +98,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
|
|||
}
|
||||
f, err := libFS.Open(relPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
|
||||
}
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
|
|
@ -103,7 +111,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
|
|||
)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
|
||||
}
|
||||
// Close in LIFO order: tf first (it holds rs internally), then f.
|
||||
defer f.Close()
|
||||
|
|
@ -127,12 +135,12 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str
|
|||
for _, regex := range picTypeRegexes {
|
||||
for i, img := range images {
|
||||
if regex.MatchString(img.Type) {
|
||||
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
|
||||
log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path)
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
|
||||
log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
@ -171,44 +179,6 @@ type readCloser struct {
|
|||
io.Closer
|
||||
}
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return r, id.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumPlaceholder() sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _ := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return fromURL(ctx, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return fromURL(ctx, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
|
|
@ -217,6 +187,12 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
|
|||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent
|
||||
// instead of retrying forever and tripping the artwork breaker.
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
resp.Body.Close()
|
||||
return nil, "", model.ErrNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status)
|
||||
|
|
|
|||
BIN
core/artwork/thumbhash/testdata/alpha.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/alpha.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
90
core/artwork/thumbhash/testdata/gen_fixtures.mjs
vendored
Normal file
90
core/artwork/thumbhash/testdata/gen_fixtures.mjs
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs
|
||||
import { writeFileSync } from 'fs'
|
||||
import { deflateSync } from 'zlib'
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
return c >>> 0
|
||||
})
|
||||
const crc32 = (buf) => {
|
||||
let c = 0xffffffff
|
||||
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8)
|
||||
return (c ^ 0xffffffff) >>> 0
|
||||
}
|
||||
const chunk = (type, data) => {
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32BE(data.length)
|
||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
||||
const crc = Buffer.alloc(4)
|
||||
crc.writeUInt32BE(crc32(body))
|
||||
return Buffer.concat([len, body, crc])
|
||||
}
|
||||
const png = (w, h, rgba) => {
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(w, 0)
|
||||
ihdr.writeUInt32BE(h, 4)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // truecolor + alpha
|
||||
const raw = Buffer.alloc(h * (w * 4 + 1))
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (w * 4 + 1)] = 0 // filter: none
|
||||
for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x]
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw)),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
}
|
||||
|
||||
// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float
|
||||
// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal.
|
||||
let seed = 0
|
||||
const dither = () => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
seed >>>= 0
|
||||
return (seed % 9) - 4
|
||||
}
|
||||
const shade = (v) => Math.max(0, Math.min(255, v + dither()))
|
||||
|
||||
const make = (w, h, fn) => {
|
||||
seed = 0x9e3779b9
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h)
|
||||
return png(w, h, rgba)
|
||||
}
|
||||
|
||||
const gradient = (rgba, i, x, y, w, h) => {
|
||||
rgba[i] = shade(Math.floor((255 * x) / w))
|
||||
rgba[i + 1] = shade(Math.floor((255 * y) / h))
|
||||
rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h)))
|
||||
rgba[i + 3] = 255
|
||||
}
|
||||
const solid = (rgba, i) => {
|
||||
rgba[i] = 60
|
||||
rgba[i + 1] = 120
|
||||
rgba[i + 2] = 180
|
||||
rgba[i + 3] = 255
|
||||
}
|
||||
// RGB must vary with position too: a constant color composited over its own average cancels to a
|
||||
// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp.
|
||||
const alphaRamp = (rgba, i, x, y, w, h) => {
|
||||
rgba[i] = shade(Math.floor((255 * x) / w))
|
||||
rgba[i + 1] = shade(Math.floor((255 * y) / h))
|
||||
rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h)))
|
||||
rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h)))
|
||||
}
|
||||
|
||||
const out = new URL('.', import.meta.url).pathname
|
||||
writeFileSync(out + 'square.png', make(100, 100, gradient))
|
||||
writeFileSync(out + 'landscape.png', make(100, 60, gradient))
|
||||
writeFileSync(out + 'portrait.png', make(60, 100, gradient))
|
||||
writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp))
|
||||
writeFileSync(out + 'solid.png', make(64, 64, solid))
|
||||
writeFileSync(out + 'tiny.png', make(1, 1, solid))
|
||||
console.log('fixtures written')
|
||||
28
core/artwork/thumbhash/testdata/gen_generated.mjs
vendored
Normal file
28
core/artwork/thumbhash/testdata/gen_generated.mjs
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference.
|
||||
// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same
|
||||
// formula and only the hashes need committing. Run: node gen_generated.mjs
|
||||
import { writeFileSync } from 'fs'
|
||||
import { rgbaToThumbHash } from './thumbhash.js'
|
||||
|
||||
const COUNT = 300
|
||||
|
||||
// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly.
|
||||
const mix = (n) => {
|
||||
n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0
|
||||
n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0
|
||||
return (n ^ (n >>> 16)) >>> 0
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const w = 1 + (mix(i * 3 + 1) % 100)
|
||||
const h = 1 + (mix(i * 3 + 2) % 100)
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255
|
||||
// Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7
|
||||
// no-alpha layout as often as the 5x5-plus-alpha one.
|
||||
if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255
|
||||
out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') })
|
||||
}
|
||||
writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n')
|
||||
console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`)
|
||||
37
core/artwork/thumbhash/testdata/gen_golden.mjs
vendored
Normal file
37
core/artwork/thumbhash/testdata/gen_golden.mjs
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Produces golden.json from the vendored reference. Run: node gen_golden.mjs
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs'
|
||||
import { inflateSync } from 'zlib'
|
||||
import { rgbaToThumbHash } from './thumbhash.js'
|
||||
|
||||
// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT.
|
||||
const readPNG = (buf) => {
|
||||
let w = 0, h = 0
|
||||
const idat = []
|
||||
for (let off = 8; off < buf.length; ) {
|
||||
const len = buf.readUInt32BE(off)
|
||||
const type = buf.toString('ascii', off + 4, off + 8)
|
||||
const data = buf.subarray(off + 8, off + 8 + len)
|
||||
if (type === 'IHDR') {
|
||||
w = data.readUInt32BE(0)
|
||||
h = data.readUInt32BE(4)
|
||||
if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA')
|
||||
} else if (type === 'IDAT') idat.push(data)
|
||||
off += 12 + len
|
||||
}
|
||||
const raw = inflateSync(Buffer.concat(idat))
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let y = 0; y < h; y++) {
|
||||
if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0')
|
||||
for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x]
|
||||
}
|
||||
return { w, h, rgba }
|
||||
}
|
||||
|
||||
const dir = new URL('.', import.meta.url).pathname
|
||||
const golden = {}
|
||||
for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) {
|
||||
const { w, h, rgba } = readPNG(readFileSync(dir + name))
|
||||
golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64')
|
||||
}
|
||||
writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n')
|
||||
console.log(golden)
|
||||
1
core/artwork/thumbhash/testdata/generated.json
vendored
Normal file
1
core/artwork/thumbhash/testdata/generated.json
vendored
Normal file
File diff suppressed because one or more lines are too long
8
core/artwork/thumbhash/testdata/golden.json
vendored
Normal file
8
core/artwork/thumbhash/testdata/golden.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==",
|
||||
"landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==",
|
||||
"portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==",
|
||||
"solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA",
|
||||
"square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I",
|
||||
"tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP"
|
||||
}
|
||||
BIN
core/artwork/thumbhash/testdata/landscape.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/landscape.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
core/artwork/thumbhash/testdata/portrait.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/portrait.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
core/artwork/thumbhash/testdata/solid.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/solid.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 B |
BIN
core/artwork/thumbhash/testdata/square.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/square.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
288
core/artwork/thumbhash/testdata/thumbhash.js
vendored
Normal file
288
core/artwork/thumbhash/testdata/thumbhash.js
vendored
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns The ThumbHash as a Uint8Array.
|
||||
*/
|
||||
export function rgbaToThumbHash(w, h, rgba) {
|
||||
// Encoding an image larger than 100x100 is slow with no benefit
|
||||
if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`)
|
||||
let { PI, round, max, cos, abs } = Math
|
||||
|
||||
// Determine the average color
|
||||
let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
let alpha = rgba[j + 3] / 255
|
||||
avg_r += alpha / 255 * rgba[j]
|
||||
avg_g += alpha / 255 * rgba[j + 1]
|
||||
avg_b += alpha / 255 * rgba[j + 2]
|
||||
avg_a += alpha
|
||||
}
|
||||
if (avg_a) {
|
||||
avg_r /= avg_a
|
||||
avg_g /= avg_a
|
||||
avg_b /= avg_a
|
||||
}
|
||||
|
||||
let hasAlpha = avg_a < w * h
|
||||
let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha
|
||||
let lx = max(1, round(l_limit * w / max(w, h)))
|
||||
let ly = max(1, round(l_limit * h / max(w, h)))
|
||||
let l = [] // luminance
|
||||
let p = [] // yellow - blue
|
||||
let q = [] // red - green
|
||||
let a = [] // alpha
|
||||
|
||||
// Convert the image from RGBA to LPQA (composite atop the average color)
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
let alpha = rgba[j + 3] / 255
|
||||
let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j]
|
||||
let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1]
|
||||
let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2]
|
||||
l[i] = (r + g + b) / 3
|
||||
p[i] = (r + g) / 2 - b
|
||||
q[i] = r - g
|
||||
a[i] = alpha
|
||||
}
|
||||
|
||||
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
|
||||
let encodeChannel = (channel, nx, ny) => {
|
||||
let dc = 0, ac = [], scale = 0, fx = []
|
||||
for (let cy = 0; cy < ny; cy++) {
|
||||
for (let cx = 0; cx * ny < nx * (ny - cy); cx++) {
|
||||
let f = 0
|
||||
for (let x = 0; x < w; x++)
|
||||
fx[x] = cos(PI / w * cx * (x + 0.5))
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++)
|
||||
f += channel[x + y * w] * fx[x] * fy
|
||||
f /= w * h
|
||||
if (cx || cy) {
|
||||
ac.push(f)
|
||||
scale = max(scale, abs(f))
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scale)
|
||||
for (let i = 0; i < ac.length; i++)
|
||||
ac[i] = 0.5 + 0.5 / scale * ac[i]
|
||||
return [dc, ac, scale]
|
||||
}
|
||||
let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly))
|
||||
let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3)
|
||||
let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3)
|
||||
let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : []
|
||||
|
||||
// Write the constants
|
||||
let isLandscape = w > h
|
||||
let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23)
|
||||
let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15)
|
||||
let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8]
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
let ac_index = 0
|
||||
if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4))
|
||||
|
||||
// Write the varying factors
|
||||
for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac])
|
||||
for (let f of ac)
|
||||
hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2)
|
||||
return new Uint8Array(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The width, height, and pixels of the rendered placeholder image.
|
||||
*/
|
||||
export function thumbHashToRGBA(hash) {
|
||||
let { PI, min, max, cos, round } = Math
|
||||
|
||||
// Read the constants
|
||||
let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
||||
let header16 = hash[3] | (hash[4] << 8)
|
||||
let l_dc = (header24 & 63) / 63
|
||||
let p_dc = ((header24 >> 6) & 63) / 31.5 - 1
|
||||
let q_dc = ((header24 >> 12) & 63) / 31.5 - 1
|
||||
let l_scale = ((header24 >> 18) & 31) / 31
|
||||
let hasAlpha = header24 >> 23
|
||||
let p_scale = ((header16 >> 3) & 63) / 63
|
||||
let q_scale = ((header16 >> 9) & 63) / 63
|
||||
let isLandscape = header16 >> 15
|
||||
let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
|
||||
let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
||||
let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1
|
||||
let a_scale = (hash[5] >> 4) / 15
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
let ac_index = 0
|
||||
let decodeChannel = (nx, ny, scale) => {
|
||||
let ac = []
|
||||
for (let cy = 0; cy < ny; cy++)
|
||||
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
|
||||
ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale)
|
||||
return ac
|
||||
}
|
||||
let l_ac = decodeChannel(lx, ly, l_scale)
|
||||
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
||||
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
||||
let a_ac = hasAlpha && decodeChannel(5, 5, a_scale)
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
let ratio = thumbHashToApproximateAspectRatio(hash)
|
||||
let w = round(ratio > 1 ? 32 : 32 * ratio)
|
||||
let h = round(ratio > 1 ? 32 / ratio : 32)
|
||||
let rgba = new Uint8Array(w * h * 4), fx = [], fy = []
|
||||
for (let y = 0, i = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++, i += 4) {
|
||||
let l = l_dc, p = p_dc, q = q_dc, a = a_dc
|
||||
|
||||
// Precompute the coefficients
|
||||
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
|
||||
fx[cx] = cos(PI / w * (x + 0.5) * cx)
|
||||
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
|
||||
fy[cy] = cos(PI / h * (y + 0.5) * cy)
|
||||
|
||||
// Decode L
|
||||
for (let cy = 0, j = 0; cy < ly; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
|
||||
l += l_ac[j] * fx[cx] * fy2
|
||||
|
||||
// Decode P and Q
|
||||
for (let cy = 0, j = 0; cy < 3; cy++) {
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
||||
let f = fx[cx] * fy2
|
||||
p += p_ac[j] * f
|
||||
q += q_ac[j] * f
|
||||
}
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if (hasAlpha)
|
||||
for (let cy = 0, j = 0; cy < 5; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++)
|
||||
a += a_ac[j] * fx[cx] * fy2
|
||||
|
||||
// Convert to RGB
|
||||
let b = l - 2 / 3 * p
|
||||
let r = (3 * l - b + q) / 2
|
||||
let g = r - q
|
||||
rgba[i] = max(0, 255 * min(1, r))
|
||||
rgba[i + 1] = max(0, 255 * min(1, g))
|
||||
rgba[i + 2] = max(0, 255 * min(1, b))
|
||||
rgba[i + 3] = max(0, 255 * min(1, a))
|
||||
}
|
||||
}
|
||||
return { w, h, rgba }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The RGBA values for the average color. Each value ranges from 0 to 1.
|
||||
*/
|
||||
export function thumbHashToAverageRGBA(hash) {
|
||||
let { min, max } = Math
|
||||
let header = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
||||
let l = (header & 63) / 63
|
||||
let p = ((header >> 6) & 63) / 31.5 - 1
|
||||
let q = ((header >> 12) & 63) / 31.5 - 1
|
||||
let hasAlpha = header >> 23
|
||||
let a = hasAlpha ? (hash[5] & 15) / 15 : 1
|
||||
let b = l - 2 / 3 * p
|
||||
let r = (3 * l - b + q) / 2
|
||||
let g = r - q
|
||||
return {
|
||||
r: max(0, min(1, r)),
|
||||
g: max(0, min(1, g)),
|
||||
b: max(0, min(1, b)),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the approximate aspect ratio of the original image.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The approximate aspect ratio (i.e. width / height).
|
||||
*/
|
||||
export function thumbHashToApproximateAspectRatio(hash) {
|
||||
let header = hash[3]
|
||||
let hasAlpha = hash[2] & 0x80
|
||||
let isLandscape = hash[4] & 0x80
|
||||
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
|
||||
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
||||
return lx / ly
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
|
||||
* A. This is optimized for speed and simplicity and does not optimize for size
|
||||
* at all. This doesn't do any compression (all values are stored uncompressed).
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns A data URL containing a PNG for the input image.
|
||||
*/
|
||||
export function rgbaToDataURL(w, h, rgba) {
|
||||
let row = w * 4 + 1
|
||||
let idat = 6 + h * (5 + row)
|
||||
let bytes = [
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0,
|
||||
idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255,
|
||||
73, 68, 65, 84, 120, 1
|
||||
]
|
||||
let table = [
|
||||
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960,
|
||||
1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376,
|
||||
-2032938284, -1609899400, -1111625188
|
||||
]
|
||||
let a = 1, b = 0
|
||||
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
||||
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0)
|
||||
for (b = (b + a) % 65521; i < end; i++) {
|
||||
let u = rgba[i] & 255
|
||||
bytes.push(u)
|
||||
a = (a + u) % 65521
|
||||
b = (b + a) % 65521
|
||||
}
|
||||
}
|
||||
bytes.push(
|
||||
b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130
|
||||
)
|
||||
for (let [start, end] of [[12, 29], [37, 41 + idat]]) {
|
||||
let c = ~0
|
||||
for (let i = start; i < end; i++) {
|
||||
c ^= bytes[i]
|
||||
c = (c >>> 4) ^ table[c & 15]
|
||||
c = (c >>> 4) ^ table[c & 15]
|
||||
}
|
||||
c = ~c
|
||||
bytes[end++] = c >>> 24
|
||||
bytes[end++] = (c >> 16) & 255
|
||||
bytes[end++] = (c >> 8) & 255
|
||||
bytes[end++] = c & 255
|
||||
}
|
||||
return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to a PNG data URL. This is a convenience function that
|
||||
* just calls "thumbHashToRGBA" followed by "rgbaToDataURL".
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns A data URL containing a PNG for the rendered ThumbHash.
|
||||
*/
|
||||
export function thumbHashToDataURL(hash) {
|
||||
let image = thumbHashToRGBA(hash)
|
||||
return rgbaToDataURL(image.w, image.h, image.rgba)
|
||||
}
|
||||
BIN
core/artwork/thumbhash/testdata/tiny.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/tiny.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
257
core/artwork/thumbhash/thumbhash.go
Normal file
257
core/artwork/thumbhash/thumbhash.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash).
|
||||
package thumbhash
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"math"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation.
|
||||
const maxInputSize = 100
|
||||
|
||||
// term is one DCT coefficient's frequency pair, in the reference's triangular scan order.
|
||||
type term struct{ cx, cy int }
|
||||
|
||||
// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches
|
||||
// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1.
|
||||
func Encode(img image.Image) ([]byte, error) {
|
||||
rgba := toNRGBA(downscale(img))
|
||||
b := rgba.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return nil, errors.New("thumbhash: empty image")
|
||||
}
|
||||
|
||||
avgR, avgG, avgB, avgA := averageColor(rgba, w, h)
|
||||
hasAlpha := avgA < float64(w*h)
|
||||
if avgA > 0 {
|
||||
avgR /= avgA
|
||||
avgG /= avgA
|
||||
avgB /= avgA
|
||||
}
|
||||
|
||||
lLimit := 7.0
|
||||
if hasAlpha {
|
||||
lLimit = 5.0
|
||||
}
|
||||
maxWH := float64(max(w, h))
|
||||
lx := max(1, int(math.Round(lLimit*float64(w)/maxWH)))
|
||||
ly := max(1, int(math.Round(lLimit*float64(h)/maxWH)))
|
||||
|
||||
lTerms := terms(max(3, lx), max(3, ly))
|
||||
pTerms := terms(3, 3)
|
||||
qTerms := terms(3, 3)
|
||||
var aTerms []term
|
||||
if hasAlpha {
|
||||
aTerms = terms(5, 5)
|
||||
}
|
||||
|
||||
// The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly.
|
||||
chan5 := 3
|
||||
if hasAlpha {
|
||||
chan5 = 5
|
||||
}
|
||||
nx := max(max(3, lx), chan5)
|
||||
cosX := cosTable(nx, w)
|
||||
cosY := cosTable(max(max(3, ly), chan5), h)
|
||||
|
||||
lAcc := make([]float64, len(lTerms))
|
||||
pAcc := make([]float64, len(pTerms))
|
||||
qAcc := make([]float64, len(qTerms))
|
||||
aAcc := make([]float64, len(aTerms))
|
||||
rowL := make([]float64, nx)
|
||||
rowP := make([]float64, nx)
|
||||
rowQ := make([]float64, nx)
|
||||
rowA := make([]float64, nx)
|
||||
|
||||
for y := range h {
|
||||
clear(rowL)
|
||||
clear(rowP)
|
||||
clear(rowQ)
|
||||
clear(rowA)
|
||||
row := rgba.Pix[y*rgba.Stride:]
|
||||
for x := range w {
|
||||
j := x * 4
|
||||
alpha := float64(row[j+3]) / 255
|
||||
r := avgR*(1-alpha) + alpha/255*float64(row[j])
|
||||
g := avgG*(1-alpha) + alpha/255*float64(row[j+1])
|
||||
bl := avgB*(1-alpha) + alpha/255*float64(row[j+2])
|
||||
lv := (r + g + bl) / 3
|
||||
pv := (r+g)/2 - bl
|
||||
qv := r - g
|
||||
for cx := range nx {
|
||||
f := cosX[cx][x]
|
||||
rowL[cx] += lv * f
|
||||
rowP[cx] += pv * f
|
||||
rowQ[cx] += qv * f
|
||||
}
|
||||
// hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter
|
||||
// of the inner loop that opaque images never need.
|
||||
if hasAlpha {
|
||||
for cx := range nx {
|
||||
rowA[cx] += alpha * cosX[cx][x]
|
||||
}
|
||||
}
|
||||
}
|
||||
accumulate(lAcc, lTerms, rowL, cosY, y)
|
||||
accumulate(pAcc, pTerms, rowP, cosY, y)
|
||||
accumulate(qAcc, qTerms, rowQ, cosY, y)
|
||||
accumulate(aAcc, aTerms, rowA, cosY, y)
|
||||
}
|
||||
|
||||
n := float64(w * h)
|
||||
lDC, lAC, lScale := normalize(lAcc, n)
|
||||
pDC, pAC, pScale := normalize(pAcc, n)
|
||||
qDC, qAC, qScale := normalize(qAcc, n)
|
||||
aDC, aAC, aScale := normalize(aAcc, n)
|
||||
|
||||
return pack(w, h, hasAlpha, lx, ly,
|
||||
lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil
|
||||
}
|
||||
|
||||
// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order.
|
||||
func terms(nx, ny int) []term {
|
||||
var ts []term
|
||||
for cy := range ny {
|
||||
for cx := 0; cx*ny < nx*(ny-cy); cx++ {
|
||||
ts = append(ts, term{cx, cy})
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the
|
||||
// table values are bit-identical to recomputing them per coefficient.
|
||||
func cosTable(n, size int) [][]float64 {
|
||||
t := make([][]float64, n)
|
||||
for c := range n {
|
||||
t[c] = make([]float64, size)
|
||||
for i := range size {
|
||||
t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5))
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) {
|
||||
for y := range h {
|
||||
row := rgba.Pix[y*rgba.Stride:]
|
||||
for x := range w {
|
||||
j := x * 4
|
||||
alpha := float64(row[j+3]) / 255
|
||||
r += alpha / 255 * float64(row[j])
|
||||
g += alpha / 255 * float64(row[j+1])
|
||||
b += alpha / 255 * float64(row[j+2])
|
||||
a += alpha
|
||||
}
|
||||
}
|
||||
return r, g, b, a
|
||||
}
|
||||
|
||||
// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs
|
||||
// nx multiplies per pixel instead of one per coefficient.
|
||||
func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) {
|
||||
for k, t := range ts {
|
||||
acc[k] += row[t.cx] * cosY[t.cy][y]
|
||||
}
|
||||
}
|
||||
|
||||
// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant
|
||||
// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint.
|
||||
func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) {
|
||||
if len(acc) == 0 {
|
||||
return 0, nil, 0
|
||||
}
|
||||
dc = acc[0] / n
|
||||
ac = make([]float64, len(acc)-1)
|
||||
for i, v := range acc[1:] {
|
||||
ac[i] = v / n
|
||||
scale = max(scale, math.Abs(ac[i]))
|
||||
}
|
||||
if scale > 0 {
|
||||
for i := range ac {
|
||||
ac[i] = 0.5 + 0.5/scale*ac[i]
|
||||
}
|
||||
}
|
||||
return dc, ac, scale
|
||||
}
|
||||
|
||||
func pack(w, h int, hasAlpha bool, lx, ly int,
|
||||
lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64,
|
||||
lAC, pAC, qAC, aAC []float64,
|
||||
) []byte {
|
||||
isLandscape := 0
|
||||
if w > h {
|
||||
isLandscape = 1
|
||||
}
|
||||
alphaBit := 0
|
||||
if hasAlpha {
|
||||
alphaBit = 1
|
||||
}
|
||||
header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 |
|
||||
int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23
|
||||
lead := lx
|
||||
if isLandscape == 1 {
|
||||
lead = ly
|
||||
}
|
||||
header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15
|
||||
|
||||
acs := [][]float64{lAC, pAC, qAC}
|
||||
acStart := 5
|
||||
if hasAlpha {
|
||||
acs = append(acs, aAC)
|
||||
acStart = 6
|
||||
}
|
||||
acCount := 0
|
||||
for _, ac := range acs {
|
||||
acCount += len(ac)
|
||||
}
|
||||
|
||||
hash := make([]byte, acStart+(acCount+1)/2)
|
||||
hash[0] = byte(header24 & 255)
|
||||
hash[1] = byte((header24 >> 8) & 255)
|
||||
hash[2] = byte(header24 >> 16)
|
||||
hash[3] = byte(header16 & 255)
|
||||
hash[4] = byte(header16 >> 8)
|
||||
if hasAlpha {
|
||||
hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4)
|
||||
}
|
||||
acIndex := 0
|
||||
for _, ac := range acs {
|
||||
for _, f := range ac {
|
||||
hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2))
|
||||
acIndex++
|
||||
}
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a
|
||||
// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in.
|
||||
func toNRGBA(img image.Image) *image.NRGBA {
|
||||
// Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the
|
||||
// copy costs nothing on the origin-anchored images the pipeline actually produces.
|
||||
if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) {
|
||||
return nrgba
|
||||
}
|
||||
b := img.Bounds()
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func downscale(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxInputSize && h <= maxInputSize {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxInputSize) / float64(max(w, h))
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
17
core/artwork/thumbhash/thumbhash_suite_test.go
Normal file
17
core/artwork/thumbhash/thumbhash_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package thumbhash_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestThumbHash(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "ThumbHash Suite")
|
||||
}
|
||||
194
core/artwork/thumbhash/thumbhash_test.go
Normal file
194
core/artwork/thumbhash/thumbhash_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package thumbhash_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/thumbhash"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs
|
||||
// the process to the repo root, which would break a plain relative "testdata" path.
|
||||
var testdataDir = func() string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "testdata")
|
||||
}()
|
||||
|
||||
// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels,
|
||||
// and RGBA would silently premultiply every fixture that has alpha.
|
||||
func fixtureImage(name string) *image.NRGBA {
|
||||
GinkgoHelper()
|
||||
f, err := os.Open(filepath.Join(testdataDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
src, _, err := image.Decode(f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b := src.Bounds()
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding
|
||||
// noise sitting on a quantization tie; only the header bytes carry signal.
|
||||
var headerOnlyFixtures = []string{"solid.png", "tiny.png"}
|
||||
|
||||
func loadJSON[T any](name string) T {
|
||||
GinkgoHelper()
|
||||
data, err := os.ReadFile(filepath.Join(testdataDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var out T
|
||||
Expect(json.Unmarshal(data, &out)).To(Succeed())
|
||||
return out
|
||||
}
|
||||
|
||||
func loadGoldens() map[string]string {
|
||||
GinkgoHelper()
|
||||
golden := loadJSON[map[string]string]("golden.json")
|
||||
Expect(golden).ToNot(BeEmpty())
|
||||
return golden
|
||||
}
|
||||
|
||||
// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the
|
||||
// same synthetic pixels and only the reference's hashes need committing.
|
||||
func mix(n uint32) uint32 {
|
||||
n = (n ^ (n >> 16)) * 2246822507
|
||||
n = (n ^ (n >> 13)) * 3266489909
|
||||
return n ^ (n >> 16)
|
||||
}
|
||||
|
||||
func generatedImage(i int) *image.NRGBA {
|
||||
w := 1 + int(mix(uint32(i)*3+1)%100)
|
||||
h := 1 + int(mix(uint32(i)*3+2)%100)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for k := range img.Pix {
|
||||
img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k)))
|
||||
}
|
||||
if i%2 == 0 {
|
||||
for k := 3; k < len(img.Pix); k += 4 {
|
||||
img.Pix[k] = 255
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Encode", func() {
|
||||
It("matches every golden vector", func() {
|
||||
for name, want := range loadGoldens() {
|
||||
if slices.Contains(headerOnlyFixtures, name) {
|
||||
continue // see the dedicated header-only spec below
|
||||
}
|
||||
got, err := thumbhash.Encode(fixtureImage(name))
|
||||
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
|
||||
Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() {
|
||||
for _, name := range headerOnlyFixtures {
|
||||
want, err := base64.StdEncoding.DecodeString(loadGoldens()[name])
|
||||
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
|
||||
got, err := thumbhash.Encode(fixtureImage(name))
|
||||
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
|
||||
Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name)
|
||||
}
|
||||
})
|
||||
|
||||
// The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the
|
||||
// 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference.
|
||||
It("matches the reference on 300 generated images", func() {
|
||||
vectors := loadJSON[[]struct {
|
||||
W, H int
|
||||
Hash string
|
||||
}]("generated.json")
|
||||
Expect(vectors).ToNot(BeEmpty())
|
||||
for i, want := range vectors {
|
||||
img := generatedImage(i)
|
||||
Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i)
|
||||
Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i)
|
||||
got, err := thumbhash.Encode(img)
|
||||
Expect(err).ToNot(HaveOccurred(), "vector %d", i)
|
||||
Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H)
|
||||
}
|
||||
})
|
||||
|
||||
It("quantizes a uniform image's scales to zero", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("solid.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16
|
||||
header16 := int(got[3]) | int(got[4])<<8
|
||||
Expect((header24>>18)&31).To(Equal(0), "lScale")
|
||||
Expect((header16>>3)&63).To(Equal(0), "pScale")
|
||||
Expect((header16>>9)&63).To(Equal(0), "qScale")
|
||||
})
|
||||
|
||||
It("produces 24 bytes for a square opaque image", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("square.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(HaveLen(24))
|
||||
})
|
||||
|
||||
It("produces 25 bytes when the image has alpha", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("alpha.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(HaveLen(25))
|
||||
})
|
||||
|
||||
It("downscales an oversized image rather than failing", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 500, 300))
|
||||
for i := range img.Pix {
|
||||
img.Pix[i] = byte(i)
|
||||
}
|
||||
got, err := thumbhash.Encode(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(got)).To(BeNumerically(">=", 5))
|
||||
})
|
||||
|
||||
It("encodes a 1x1 image", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255})
|
||||
got, err := thumbhash.Encode(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("encodes a sub-image like an origin-anchored copy of the same region", func() {
|
||||
parent := image.NewNRGBA(image.Rect(0, 0, 60, 50))
|
||||
for i := range parent.Pix {
|
||||
parent.Pix[i] = byte(i * 7 % 251)
|
||||
}
|
||||
region := image.Rect(10, 7, 40, 30)
|
||||
cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy()))
|
||||
draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src)
|
||||
|
||||
got, err := thumbhash.Encode(parent.SubImage(region))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
want, err := thumbhash.Encode(cropped)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(want))
|
||||
})
|
||||
|
||||
It("rejects an empty image", func() {
|
||||
_, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := fixtureImage("square.png")
|
||||
first, err := thumbhash.Encode(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
second, err := thumbhash.Encode(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(first).To(Equal(second))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package core
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -15,13 +15,7 @@ import (
|
|||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type ImageUploadService interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default
|
||||
// when it's unset/invalid. Shared by every API that accepts image uploads.
|
||||
// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default.
|
||||
func MaxImageUploadSize() int64 {
|
||||
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
|
||||
return int64(size)
|
||||
|
|
@ -30,13 +24,30 @@ func MaxImageUploadSize() int64 {
|
|||
return int64(size)
|
||||
}
|
||||
|
||||
type imageUploadService struct{}
|
||||
|
||||
func NewImageUploadService() ImageUploadService {
|
||||
return &imageUploadService{}
|
||||
// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state.
|
||||
type Uploader interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
// EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new
|
||||
// filename, or the worker resolves the old one.
|
||||
EnqueueArtwork(ctx context.Context, entityType, entityID string)
|
||||
}
|
||||
|
||||
func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||
var uploadEntityKind = map[string]model.Kind{
|
||||
consts.EntityArtist: model.KindArtistArtwork,
|
||||
consts.EntityPlaylist: model.KindPlaylistArtwork,
|
||||
consts.EntityRadio: model.KindRadioArtwork,
|
||||
}
|
||||
|
||||
type uploader struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewUploader(ds model.DataStore) Uploader {
|
||||
return &uploader{ds: ds}
|
||||
}
|
||||
|
||||
func (s *uploader) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||
filename := imageFilename(entityID, name, ext)
|
||||
absPath := model.UploadedImagePath(entityType, filename)
|
||||
|
||||
|
|
@ -44,14 +55,12 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en
|
|||
return "", fmt.Errorf("creating image directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove old image if it exists
|
||||
if oldPath != "" {
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
|
||||
log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new image
|
||||
f, err := os.Create(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating image file: %w", err)
|
||||
|
|
@ -61,11 +70,20 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en
|
|||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return "", fmt.Errorf("writing image file: %w", err)
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error {
|
||||
func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
|
||||
kind, ok := uploadEntityKind[entityType]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := Refresh(ctx, s.ds, kind, id); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uploader) RemoveImage(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package core_test
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -9,20 +9,26 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageUploadService", func() {
|
||||
var svc core.ImageUploadService
|
||||
var _ = Describe("Uploader", func() {
|
||||
var svc Uploader
|
||||
var tmpDir string
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
var queueRepo *tests.MockArtworkQueueRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
svc = core.NewImageUploadService()
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
|
||||
svc = NewUploader(ds)
|
||||
})
|
||||
|
||||
Describe("SetImage", func() {
|
||||
|
|
@ -69,6 +75,49 @@ var _ = Describe("ImageUploadService", func() {
|
|||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||
// the caller has persisted the new filename and called EnqueueArtwork.
|
||||
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueArtwork", func() {
|
||||
It("clears artwork state and enqueues a Bump", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
queued, err := queueRepo.DequeueBatch(1000)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued).To(ContainElement(SatisfyAll(
|
||||
HaveField("ItemKind", "ar"),
|
||||
HaveField("ItemID", "ar-1"),
|
||||
HaveField("Priority", model.ArtworkPriorityBump),
|
||||
)))
|
||||
})
|
||||
|
||||
It("is a no-op for an unknown entity type", func() {
|
||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
|
|
@ -105,21 +154,21 @@ var _ = Describe("MaxImageUploadSize", func() {
|
|||
|
||||
It("returns the configured size when valid", func() {
|
||||
conf.Server.MaxImageUploadSize = "20MB"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000)))
|
||||
Expect(MaxImageUploadSize()).To(Equal(int64(20_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is empty", func() {
|
||||
conf.Server.MaxImageUploadSize = ""
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is invalid", func() {
|
||||
conf.Server.MaxImageUploadSize = "not-a-size"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("parses raw byte values", func() {
|
||||
conf.Server.MaxImageUploadSize = "52428800"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
||||
Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
||||
})
|
||||
})
|
||||
|
|
@ -7,5 +7,7 @@ import (
|
|||
var Set = wire.NewSet(
|
||||
NewArtwork,
|
||||
GetImageCache,
|
||||
NewCacheWarmer,
|
||||
NewWorker,
|
||||
GetImageStore,
|
||||
NewUploader,
|
||||
)
|
||||
|
|
|
|||
321
core/artwork/worker.go
Normal file
321
core/artwork/worker.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
workerPollInterval = 5 * time.Second
|
||||
backoffBase = 5 * time.Second
|
||||
// giveUpAfter bounds the retry budget from enqueue; past it the item falls to the
|
||||
// periodic stale-absent recheck.
|
||||
giveUpAfter = 12 * time.Hour
|
||||
)
|
||||
|
||||
// drainPool drains one class of work with its own slot budget, so a blocking kind cannot
|
||||
// occupy slots another kind needs.
|
||||
type drainPool struct {
|
||||
name string
|
||||
kinds []string
|
||||
concurrency int
|
||||
}
|
||||
|
||||
// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken
|
||||
// independently, and pruneMu serializes prune against the store-write window.
|
||||
type Worker struct {
|
||||
proc *processor
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
broker events.Broker
|
||||
pruneMu sync.RWMutex
|
||||
pools []*drainPool
|
||||
runCtx context.Context
|
||||
|
||||
gatesMu sync.Mutex
|
||||
gates map[string]*extGate
|
||||
}
|
||||
|
||||
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
||||
w := &Worker{
|
||||
proc: &processor{ds: ds, store: store},
|
||||
cache: imgCache,
|
||||
ffmpeg: ffmpeg,
|
||||
broker: broker,
|
||||
pools: newDrainPools(),
|
||||
runCtx: context.Background(),
|
||||
gates: map[string]*extGate{},
|
||||
}
|
||||
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
|
||||
w.proc.pruneLock = w.pruneMu.RLocker()
|
||||
return w
|
||||
}
|
||||
|
||||
// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its
|
||||
// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk.
|
||||
func newDrainPools() []*drainPool {
|
||||
budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive
|
||||
local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1)
|
||||
// More external slots than the rate allows would only sleep in the limiter.
|
||||
external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local)
|
||||
return []*drainPool{
|
||||
{name: "local", kinds: localDrainKinds, concurrency: local},
|
||||
{name: "external", kinds: externalDrainKinds, concurrency: external},
|
||||
}
|
||||
}
|
||||
|
||||
// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot.
|
||||
var (
|
||||
externalDrainKinds = []string{model.KindArtistArtwork.Prefix()}
|
||||
localDrainKinds = []string{
|
||||
model.KindAlbumArtwork.Prefix(),
|
||||
model.KindPlaylistArtwork.Prefix(),
|
||||
model.KindRadioArtwork.Prefix(),
|
||||
model.KindMediaFileArtwork.Prefix(),
|
||||
}
|
||||
)
|
||||
|
||||
// Run blocks draining the queue until ctx is cancelled.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
w.runCtx = ctx
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range w.pools {
|
||||
wg.Go(func() { w.runPool(ctx, p) })
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) runPool(ctx context.Context, p *drainPool) {
|
||||
ticker := time.NewTicker(workerPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
n, err := w.drain(ctx, p.concurrency, p.kinds...)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunPrune runs prune under the worker's write lock, so no acquisition can place
|
||||
// a file while orphans are being reclaimed. This is the only sanctioned prune path.
|
||||
func (w *Worker) RunPrune(ctx context.Context) error {
|
||||
w.pruneMu.Lock()
|
||||
defer w.pruneMu.Unlock()
|
||||
return prune(ctx, w.proc.ds, w.proc.store)
|
||||
}
|
||||
|
||||
// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
|
||||
// artists first. It reports whether anything was enqueued.
|
||||
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
|
||||
return backfill(ctx, w.proc.ds)
|
||||
}
|
||||
|
||||
// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge.
|
||||
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
|
||||
return enqueueStaleAbsentAll(ctx, w.proc.ds)
|
||||
}
|
||||
|
||||
// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything
|
||||
// a scan never enqueued.
|
||||
func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
|
||||
return enqueueMissingAll(ctx, w.proc.ds)
|
||||
}
|
||||
|
||||
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
|
||||
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
drainStart := time.Now()
|
||||
// Private playlists need an admin identity; resolved per drain because the worker can
|
||||
// start before any admin exists.
|
||||
ctx = auth.WithAdminUser(ctx, w.proc.ds)
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
var refreshMu sync.Mutex
|
||||
var refresh []model.ArtworkQueueItem
|
||||
for _, item := range items {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
// select picks randomly when both cases are ready, so re-check to never dispatch after cancellation.
|
||||
if ctx.Err() != nil {
|
||||
wg.Wait()
|
||||
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
|
||||
}
|
||||
wg.Go(func() {
|
||||
defer func() { <-sem }()
|
||||
out, got := w.process(ctx, item)
|
||||
// Absent counts as a visible change too: clients must drop a previously-served
|
||||
// immutable cover.
|
||||
if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent {
|
||||
refreshMu.Lock()
|
||||
refresh = append(refresh, item)
|
||||
refreshMu.Unlock()
|
||||
}
|
||||
// Post-outcome: the queue row is already settled, so warming the cache can't
|
||||
// block or alter queue ops.
|
||||
if got != nil {
|
||||
w.precache(ctx, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
w.broadcastRefresh(ctx, refresh)
|
||||
log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items),
|
||||
"refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart))
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so
|
||||
// this can't derive from Kind.String().
|
||||
var artworkKindToResource = map[model.Kind]string{
|
||||
model.KindAlbumArtwork: "album",
|
||||
model.KindArtistArtwork: "artist",
|
||||
model.KindPlaylistArtwork: "playlist",
|
||||
model.KindRadioArtwork: "radio",
|
||||
model.KindMediaFileArtwork: "song",
|
||||
}
|
||||
|
||||
// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the
|
||||
// affected records and pick up the new coverArt id.
|
||||
func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) {
|
||||
if len(found) == 0 {
|
||||
return
|
||||
}
|
||||
event := &events.RefreshResource{}
|
||||
byResource := map[string][]string{}
|
||||
for _, it := range found {
|
||||
kind, _ := model.ParseKind(it.ItemKind)
|
||||
if res, ok := artworkKindToResource[kind]; ok {
|
||||
byResource[res] = append(byResource[res], it.ItemID)
|
||||
}
|
||||
}
|
||||
if len(byResource) == 0 {
|
||||
return
|
||||
}
|
||||
for res, ids := range byResource {
|
||||
event = event.With(res, ids...)
|
||||
}
|
||||
w.broker.SendBroadcastMessage(ctx, event)
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||
out, got := w.proc.acquire(ctx, item)
|
||||
|
||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||
switch out {
|
||||
case outcomeFound, outcomeAbsent:
|
||||
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
||||
// here and the next drain re-resolves it.
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
retryAt := time.Now().Add(backoff(item.Attempts))
|
||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||
// fresh, immediate eligibility.
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt),
|
||||
"budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter)))
|
||||
break
|
||||
}
|
||||
// Absent is only recoverable where a periodic recheck revisits it, so other kinds keep
|
||||
// no row; art already being served is kept, as exhaustion means unreachable, not removed.
|
||||
settled := "kept previous state"
|
||||
if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
||||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
||||
settled = "recorded absent"
|
||||
}
|
||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
return out, got
|
||||
}
|
||||
|
||||
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
|
||||
kind, ok := model.ParseKind(item.ItemKind)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
|
||||
// precache warms the resize cache at the UI cover size from the bytes just acquired, so the
|
||||
// first UI request hits without re-reading the rows or the file.
|
||||
func (w *Worker) precache(ctx context.Context, got *acquired) {
|
||||
if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) {
|
||||
return
|
||||
}
|
||||
precacheStart := time.Now()
|
||||
// Same key as the serving path: square must match what the list surfaces request, or this
|
||||
// warms a key nothing reads.
|
||||
item := &resizedItem{
|
||||
hash: got.ia.Hash,
|
||||
size: conf.Server.UICoverArtSize,
|
||||
square: true,
|
||||
ffmpeg: w.ffmpeg,
|
||||
open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil },
|
||||
}
|
||||
stream, err := w.cache.Get(ctx, item)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err)
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, stream)
|
||||
_ = stream.Close()
|
||||
log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID,
|
||||
"size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart))
|
||||
}
|
||||
|
||||
// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4].
|
||||
func backoffFor(attempts int, jitter float64) time.Duration {
|
||||
d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter))
|
||||
return time.Duration(d * (1 + jitter))
|
||||
}
|
||||
|
||||
func backoff(attempts int) time.Duration {
|
||||
return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive
|
||||
}
|
||||
146
core/artwork/worker_soak_test.go
Normal file
146
core/artwork/worker_soak_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Deliberately >2000: a leak guard favors a stable signal over speed.
|
||||
const soakCycles = 2200
|
||||
|
||||
var _ = Describe("Worker soak", func() {
|
||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||
if testing.Short() {
|
||||
Skip("skipping soak test in short mode")
|
||||
}
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
folderRepo := &fakeFolderRepo{result: []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}}
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
ag := agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
artRepo := tests.CreateMockArtworkRepo()
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}},
|
||||
{ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
ds := &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
store := NewImageStore(GinkgoT().TempDir())
|
||||
proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)}
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
|
||||
// Dangling refs mirror an entity deleted after being enqueued.
|
||||
items := []model.ArtworkQueueItem{
|
||||
{ItemKind: "al", ItemID: "al-folder"},
|
||||
{ItemKind: "al", ItemID: "al-embed"},
|
||||
{ItemKind: "al", ItemID: "al-does-not-exist"},
|
||||
{ItemKind: "ra", ItemID: "ra-does-not-exist"},
|
||||
}
|
||||
|
||||
fdCount := func() int {
|
||||
if runtime.GOOS != "linux" {
|
||||
return -1
|
||||
}
|
||||
entries, err := os.ReadDir("/proc/self/fd")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
|
||||
settleGoroutines := func() int {
|
||||
// Background goroutines wind down late, so poll for two consecutive equal samples.
|
||||
prev := -1
|
||||
for range 100 {
|
||||
runtime.GC()
|
||||
n := runtime.NumGoroutine()
|
||||
if n == prev {
|
||||
return n
|
||||
}
|
||||
prev = n
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
baselineGoroutines := settleGoroutines()
|
||||
baselineFDs := fdCount()
|
||||
|
||||
var heapAt10Pct uint64
|
||||
start := time.Now()
|
||||
for i := range soakCycles {
|
||||
it := items[i%len(items)]
|
||||
out, _ := proc.acquire(context.Background(), it)
|
||||
|
||||
// Read-back exercises the surfaces a caller would use after acquisition.
|
||||
if out == outcomeFound {
|
||||
kind, _ := model.ParseKind(it.ItemKind)
|
||||
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, _ = io.Copy(io.Discard, rc)
|
||||
rc.Close()
|
||||
case os.IsNotExist(err):
|
||||
// Folder-backed art has no store file; that's expected.
|
||||
default:
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i)
|
||||
}
|
||||
}
|
||||
|
||||
if i == soakCycles/10 {
|
||||
runtime.GC()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
heapAt10Pct = ms.HeapAlloc
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
finalGoroutines := settleGoroutines()
|
||||
finalFDs := fdCount()
|
||||
|
||||
runtime.GC()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
|
||||
GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n",
|
||||
soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs)
|
||||
|
||||
Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines)
|
||||
if heapAt10Pct > 0 {
|
||||
Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc)
|
||||
}
|
||||
if runtime.GOOS == "linux" && baselineFDs >= 0 {
|
||||
Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs)
|
||||
}
|
||||
})
|
||||
})
|
||||
817
core/artwork/worker_test.go
Normal file
817
core/artwork/worker_test.go
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
type recordingCache struct {
|
||||
cache.FileCache
|
||||
mu sync.Mutex
|
||||
keys []string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func (c *recordingCache) Disabled(ctx context.Context) bool {
|
||||
return c.disabled || c.FileCache.Disabled(ctx)
|
||||
}
|
||||
|
||||
func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) {
|
||||
c.mu.Lock()
|
||||
c.keys = append(c.keys, arg.Key())
|
||||
c.mu.Unlock()
|
||||
return c.FileCache.Get(ctx, arg)
|
||||
}
|
||||
|
||||
func (c *recordingCache) getKeys() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return slices.Clone(c.keys)
|
||||
}
|
||||
|
||||
// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so
|
||||
// DeleteIfUnchanged on the dequeued value no-ops.
|
||||
type reenqueueOnDequeue struct {
|
||||
*tests.MockArtworkQueueRepo
|
||||
done bool
|
||||
}
|
||||
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
||||
if !r.done && len(items) > 0 {
|
||||
r.done = true
|
||||
for k, it := range r.Data {
|
||||
if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID {
|
||||
it.RetryAt = items[0].RetryAt.Add(time.Minute)
|
||||
r.Data[k] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
type fakeEventBroker struct {
|
||||
http.Handler
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.events = append(f.events, event)
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.events = append(f.events, event)
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) getEvents() []events.Event {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return slices.Clone(f.events)
|
||||
}
|
||||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
|
||||
func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem {
|
||||
for _, it := range q.Data {
|
||||
if it.ItemKind == kind && it.ItemID == id {
|
||||
return &it
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = Describe("Worker", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
ag *agents.Agents
|
||||
store *ImageStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
broker *fakeEventBroker
|
||||
imgCache *recordingCache
|
||||
repoRoot string
|
||||
w *Worker
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests
|
||||
broker = &fakeEventBroker{}
|
||||
imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
return arg.(artworkReader).Reader(ctx)
|
||||
})}
|
||||
// Init walks the cache dir on a goroutine; loaded CI runners can take >1s.
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
})
|
||||
|
||||
Describe("drain", func() {
|
||||
It("processes a seeded queue item and removes it from the queue", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||
})
|
||||
|
||||
It("processes an mf queue item, writing state and storing embedded bytes", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
|
||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
})
|
||||
|
||||
It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
it := findQueued(queueRepo, "al", "al4")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||
})
|
||||
|
||||
It("reschedules a found-stale item via MarkFailed while keeping its served state", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
it := findQueued(queueRepo, "al", "alstale")
|
||||
Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry")
|
||||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||
|
||||
evts := broker.getEvents()
|
||||
Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI")
|
||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale"))
|
||||
})
|
||||
|
||||
It("keeps a row re-enqueued between dequeue and delete", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts.
|
||||
it := findQueued(queueRepo, "al", "al8")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(it.Attempts).To(BeZero())
|
||||
Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute)))
|
||||
})
|
||||
|
||||
It("gives up and settles absent once the retry budget is exhausted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
// Age the row past the retry budget.
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al9" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps already-served art when the retry budget is exhausted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al10" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||
})
|
||||
|
||||
// Media files are excluded from recheckKinds, so an absent row here would never be
|
||||
// revisited: a transient read error would look permanent.
|
||||
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
|
||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "mfX" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"no row leaves the track unresolved, so a later view can still recover it")
|
||||
})
|
||||
|
||||
It("resolves a private playlist under an admin context instead of failing forever", func() {
|
||||
ds.MockedUser = adminUserRepo()
|
||||
vds := &visibilityPlaylistDS{
|
||||
MockDataStore: ds,
|
||||
private: model.Playlist{ID: "plPriv", OwnerID: "admin"},
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns zero when the queue is empty", func() {
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(BeZero())
|
||||
})
|
||||
|
||||
It("broadcasts a single refresh event for the found items in a batch", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
||||
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(3))
|
||||
|
||||
evts := broker.getEvents()
|
||||
Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch")
|
||||
rr, ok := evts[0].(*events.RefreshResource)
|
||||
Expect(ok).To(BeTrue())
|
||||
data := rr.Data(rr)
|
||||
Expect(data).To(ContainSubstring(`"album"`))
|
||||
Expect(data).To(ContainSubstring("al1"))
|
||||
Expect(data).To(ContainSubstring("al2"))
|
||||
Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed")
|
||||
Expect(data).ToNot(ContainSubstring("ar1"))
|
||||
})
|
||||
|
||||
DescribeTable("only lists the kinds it actually resolved",
|
||||
func(kinds []string, wantSong bool) {
|
||||
items := slice.Map(kinds, func(k string) model.ArtworkQueueItem {
|
||||
return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"}
|
||||
})
|
||||
w.broadcastRefresh(ctx, items)
|
||||
|
||||
evts := broker.getEvents()
|
||||
Expect(evts).To(HaveLen(1))
|
||||
data := evts[0].(*events.RefreshResource).Data(evts[0])
|
||||
if wantSong {
|
||||
Expect(data).To(ContainSubstring(`"song"`))
|
||||
} else {
|
||||
Expect(data).ToNot(ContainSubstring(`"song"`))
|
||||
}
|
||||
},
|
||||
// An album's tracks inherit its art, but the dependent ids are unbounded: the client
|
||||
// fans an album refresh out to the tracks it has loaded.
|
||||
Entry("album alone does not name songs", []string{"al"}, false),
|
||||
Entry("artist alone does not", []string{"ar"}, false),
|
||||
Entry("playlist alone does not", []string{"pl"}, false),
|
||||
Entry("album mixed with others still does not", []string{"ar", "al"}, false),
|
||||
Entry("songs resolving on their own are listed by id", []string{"mf"}, true),
|
||||
)
|
||||
|
||||
It("broadcasts a refresh when an item resolves to absent (removed cover)", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
||||
folderRepo.result = nil
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
evts := broker.getEvents()
|
||||
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||
})
|
||||
|
||||
It("does not broadcast when no item is found", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("gate/breaker", func() {
|
||||
It("opens after 5 consecutive external errors and short-circuits the step", func() {
|
||||
var calls int
|
||||
failing := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
for range 5 {
|
||||
_, _, err := w.gate("A", failing)
|
||||
Expect(err).To(HaveOccurred())
|
||||
}
|
||||
Expect(calls).To(Equal(5))
|
||||
|
||||
_, _, err := w.gate("A", failing)
|
||||
Expect(err).To(MatchError(errBreakerOpen))
|
||||
Expect(calls).To(Equal(5), "an open breaker must not call the external step")
|
||||
})
|
||||
|
||||
It("resets the failure count on a successful call", func() {
|
||||
failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
|
||||
ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil }
|
||||
for range 4 {
|
||||
_, _, _ = w.gate("A", failing)
|
||||
}
|
||||
_, _, err := w.gate("A", ok)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var calls int
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
for range 5 {
|
||||
_, _, _ = w.gate("A", counting)
|
||||
}
|
||||
Expect(calls).To(Equal(5), "the breaker should have re-closed after the success")
|
||||
})
|
||||
|
||||
It("does not open the breaker on a run of agent not-found misses", func() {
|
||||
// agents.ErrNotFound is a definitive miss, not a fault: artless items must not
|
||||
// trip the breaker, or they would loop in retry instead of settling absent.
|
||||
notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
|
||||
for range breakerThreshold + 3 {
|
||||
_, _, err := w.gate("A", notFound)
|
||||
Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen")
|
||||
}
|
||||
|
||||
var calls int
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
_, _, _ = w.gate("A", counting)
|
||||
Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs")
|
||||
})
|
||||
|
||||
It("isolates each agent's breaker: one open gate does not block another", func() {
|
||||
failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
|
||||
for range breakerThreshold {
|
||||
_, _, _ = w.gate("A", failing)
|
||||
}
|
||||
_, _, err := w.gate("A", failing)
|
||||
Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open")
|
||||
|
||||
var bCalls int
|
||||
bStep := func() (io.ReadCloser, string, error) {
|
||||
bCalls++
|
||||
return io.NopCloser(nil), "p", nil
|
||||
}
|
||||
for range breakerThreshold + 2 {
|
||||
_, _, err := w.gate("B", bStep)
|
||||
Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open")
|
||||
}
|
||||
Expect(bCalls).To(Equal(breakerThreshold + 2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("precache", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
conf.Server.UICoverArtSize = 300
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
})
|
||||
|
||||
It("warms the resize cache at the UI cover size after a found acquisition", func() {
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// The list surfaces request square covers, so warming any other key is wasted work.
|
||||
Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.true.")))
|
||||
})
|
||||
|
||||
It("skips warming when precache is disabled", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(imgCache.getKeys()).To(BeEmpty())
|
||||
})
|
||||
|
||||
// It warms from the bytes acquisition already held, so no state row or store file is needed.
|
||||
It("warms from the acquired bytes without reading them back", func() {
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
ia := &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary,
|
||||
Hash: "abcdef0123456789", UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("tests/fixtures/artist/an-album/cover.jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data})
|
||||
|
||||
// Nothing backs that hash on disk, so a hit can only come from the bytes handed in;
|
||||
// the probe refuses to open, proving nothing is re-read.
|
||||
probe := &resizedItem{
|
||||
hash: ia.Hash, size: 300, square: true, ffmpeg: ffm,
|
||||
open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") },
|
||||
}
|
||||
stream, err := imgCache.Get(ctx, probe)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
Expect(io.ReadAll(stream)).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("drain pools", func() {
|
||||
// A kind in neither pool is never dequeued, with nothing to catch it at compile time.
|
||||
It("covers every kind the worker can process, exactly once", func() {
|
||||
var pooled []string
|
||||
for _, p := range newDrainPools() {
|
||||
pooled = append(pooled, p.kinds...)
|
||||
}
|
||||
for kind := range artworkKindToResource {
|
||||
Expect(pooled).To(ContainElement(kind.Prefix()), "kind %q belongs to no drain pool", kind.Prefix())
|
||||
}
|
||||
Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool")
|
||||
})
|
||||
|
||||
// Artists resolve through a rate-limited agent that holds its slot while waiting, so one
|
||||
// shared pool would park every album behind them.
|
||||
It("resolves albums while artists are stuck on a slow agent", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
ds.MockedArtist = tests.CreateMockArtistRepo()
|
||||
|
||||
// Every artist lookup blocks until released, standing in for the rate limiter.
|
||||
block := make(chan struct{})
|
||||
artists := model.Artists{}
|
||||
for i := range 8 {
|
||||
artists = append(artists, model.Artist{ID: fmt.Sprintf("arx%d", i), Name: "A"})
|
||||
}
|
||||
ds.MockedArtist.(*tests.MockArtistRepo).SetData(artists)
|
||||
imageAgents(&fakeImageAgent{name: "slowAgent", block: block})
|
||||
|
||||
// Artists first, exactly as Backfill orders them.
|
||||
for _, a := range artists {
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
}
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); _ = w.Run(runCtx) }()
|
||||
// Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores.
|
||||
DeferCleanup(func() {
|
||||
cancel()
|
||||
close(block) // unpark the blocked lookups so the pools can unwind
|
||||
<-done
|
||||
})
|
||||
|
||||
Eventually(func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||
"a blocked external pool must not hold up local artwork")
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("batching", func() {
|
||||
It("leaves undispatched items queued when cancelled mid-batch", func() {
|
||||
// Every album must resolve, so any dispatched item deletes its row regardless of dequeue order.
|
||||
albums := model.Albums{}
|
||||
for i := range 8 {
|
||||
id := fmt.Sprintf("alc%d", i)
|
||||
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
||||
cancelledCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
_, err := w.drain(cancelledCtx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for i := range 8 {
|
||||
id := fmt.Sprintf("alc%d", i)
|
||||
Expect(findQueued(queueRepo, "al", id)).ToNot(BeNil(), "row "+id+" must survive a cancelled drain")
|
||||
}
|
||||
})
|
||||
|
||||
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||
for i := range 16 {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(16), "a batch sized to the pool would have stopped at 4")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RunPrune", func() {
|
||||
It("runs a prune under the worker mutex", func() {
|
||||
Expect(w.RunPrune(ctx)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Run", func() {
|
||||
It("exits cleanly when the context is cancelled", func() {
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(runCtx) }()
|
||||
|
||||
cancel()
|
||||
Eventually(done, time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
|
||||
It("does not leak goroutines after Run exits", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
ignore := goleak.IgnoreCurrent()
|
||||
DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) })
|
||||
|
||||
localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()}
|
||||
lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache)
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- lw.Run(runCtx) }()
|
||||
|
||||
time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("backoff", func() {
|
||||
It("returns the expected schedule with no jitter", func() {
|
||||
for _, c := range []struct {
|
||||
attempts int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 5 * time.Second},
|
||||
{1, 20 * time.Second},
|
||||
{2, 80 * time.Second},
|
||||
{3, 320 * time.Second},
|
||||
{4, 1280 * time.Second},
|
||||
{5, 5120 * time.Second},
|
||||
{6, 20480 * time.Second},
|
||||
{7, 12 * time.Hour},
|
||||
{8, 12 * time.Hour},
|
||||
} {
|
||||
Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts)
|
||||
}
|
||||
})
|
||||
|
||||
It("applies jitter proportionally", func() {
|
||||
base := backoffFor(2, 0)
|
||||
Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2)))
|
||||
Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8)))
|
||||
})
|
||||
|
||||
It("keeps random jitter within +/-40%", func() {
|
||||
lo := time.Duration(float64(320*time.Second) * 0.6)
|
||||
hi := time.Duration(float64(320*time.Second) * 1.4)
|
||||
for range 200 {
|
||||
d := backoff(3)
|
||||
Expect(d).To(BeNumerically(">=", lo))
|
||||
Expect(d).To(BeNumerically("<=", hi))
|
||||
}
|
||||
})
|
||||
})
|
||||
76
core/artwork/worker_timing_test.go
Normal file
76
core/artwork/worker_timing_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest
|
||||
// needs a *testing.T, which Ginkgo doesn't give.
|
||||
func TestArtworkBreakerHalfOpen(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
b := newBreaker()
|
||||
|
||||
for range breakerThreshold {
|
||||
b.record("agentA", errors.New("boom"))
|
||||
}
|
||||
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
|
||||
|
||||
time.Sleep(breakerProbeAfter - time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval")
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
|
||||
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
|
||||
|
||||
b.record("agentA", errors.New("boom")) // probe fails -> stay open
|
||||
time.Sleep(breakerProbeAfter)
|
||||
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
|
||||
|
||||
b.record("agentA", nil) // probe succeeds -> close
|
||||
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
|
||||
g.Expect(b.allow()).To(BeTrue())
|
||||
})
|
||||
}
|
||||
|
||||
func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()),
|
||||
agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil)
|
||||
|
||||
fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
|
||||
for range breakerThreshold {
|
||||
_, _, _ = w.gate("A", fail)
|
||||
}
|
||||
_, _, err := w.gate("A", fail)
|
||||
g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors")
|
||||
|
||||
// B has its own breaker, untouched by A being open.
|
||||
var bCalls int
|
||||
bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") }
|
||||
for range breakerThreshold - 1 {
|
||||
_, _, err := w.gate("B", bStep)
|
||||
g.Expect(err).To(MatchError("boom"))
|
||||
}
|
||||
g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open")
|
||||
|
||||
// After the probe window, A admits exactly one probe again.
|
||||
time.Sleep(breakerProbeAfter)
|
||||
var aCalls int
|
||||
aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") }
|
||||
_, _, _ = w.gate("A", aFail)
|
||||
g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval")
|
||||
_, _, err = w.gate("A", aFail)
|
||||
g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open")
|
||||
g.Expect(aCalls).To(Equal(1))
|
||||
})
|
||||
}
|
||||
85
core/external/provider.go
vendored
85
core/external/provider.go
vendored
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -35,8 +34,6 @@ type Provider interface {
|
|||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
|
|
@ -258,7 +255,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
|||
// Call all registered agents and collect information
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
|
|
@ -370,76 +367,6 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in
|
|||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
// No cached URL — must fetch from external source synchronously
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
imageUrl = artist.ArtistImageUrl()
|
||||
} else {
|
||||
// If cached info is expired, enqueue a background refresh so that config changes
|
||||
// (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
}
|
||||
|
||||
if imageUrl == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
albumName := album.Name()
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
var img agents.ExternalImage
|
||||
for _, i := range images {
|
||||
if img.Size <= i.Size {
|
||||
img = i
|
||||
}
|
||||
}
|
||||
if img.URL == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(img.URL)
|
||||
}
|
||||
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtist(ctx, artistName, id)
|
||||
if err != nil {
|
||||
|
|
@ -524,10 +451,15 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
|
|||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
// callGetImage populates artist's image URLs. A transient agent failure is
|
||||
// returned as-is; a definitive "no image" is normalized to model.ErrNotFound.
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) error {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
||||
|
|
@ -540,6 +472,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
|
|||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
|
|
|
|||
365
core/external/provider_albumimage_test.go
vendored
365
core/external/provider_albumimage_test.go
vendored
|
|
@ -1,365 +0,0 @@
|
|||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - AlbumImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("agent failure"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(ctx)
|
||||
// Mock the necessary DB calls *before* canceling the context
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Expect the agent call even if context is cancelled, returning the context error
|
||||
mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||||
// Cancel the context *before* calling the function under test
|
||||
cancelCtx()
|
||||
|
||||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("context canceled"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// Agent should now be called, verify this expectation
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("derives album ID from MediaFile ID", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
Context("Unicode handling in album names", func() {
|
||||
var albumWithEnDash *model.Album
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
|
||||
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in album name
|
||||
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/album.jpg")
|
||||
|
||||
// Mock the album agent to return an image for the album
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/album.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
|
||||
It("preserves Unicode characters in album names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
|
||||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||||
m := new(mockAlbumInfoAgent)
|
||||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interfaces
|
||||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue