diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index d8e832cf1..742b8b1a5 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.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 { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index f478af115..360db1f13 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -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 diff --git a/cmd/pls.go b/cmd/pls.go index 95cbe4eec..0203fe3a9 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -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, core.NewImageUploadService(ds)) for _, file := range files { absPath, err := filepath.Abs(file) diff --git a/cmd/root.go b/cmd/root.go index 9e2b38cd8..4df5c1cb8 100644 --- a/cmd/root.go +++ b/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,60 @@ 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. Scan-triggered prune lands in a later phase. +func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + ds := CreateDataStore() + schedulerInstance := scheduler.GetInstance() + + if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { + if err := artwork.EnqueueStaleAbsentAll(ctx, ds); err != nil { + log.Error(ctx, "Error enqueueing stale 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) + } + + backfilled, err := artwork.Backfill(ctx, ds) + 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 { diff --git a/cmd/scan.go b/cmd/scan.go index 320b401d4..c75a7aed4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -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, core.NewImageUploadService(ds)) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c4a797ef4..82d0ecc89 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -65,21 +65,14 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) 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) @@ -91,29 +84,29 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() fFmpeg := ffmpeg.New() + service := artwork.NewService(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() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) + router := subsonic.New(dataStore, service, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -121,24 +114,25 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() 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) + service := artwork.NewService(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() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + 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) + router := jellyfin.New(dataStore, service, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } @@ -146,19 +140,14 @@ func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() 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) + service := artwork.NewService(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) - router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + router := public.New(dataStore, service, mediaStreamer, share, archiver) return router } @@ -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() - 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() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + 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() - 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() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + 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.ProvideImageStore() + 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) diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..a835e5c4c 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -136,6 +136,12 @@ func GetPlaybackServer() playback.PlaybackServer { )) } +func CreateArtworkWorker() *artwork.Worker { + panic(wire.Build( + allProviders, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 83793bd43..9eeda89de 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -57,6 +57,8 @@ type configOptions struct { ImageCacheSize string AlbumPlayCountMode string EnableArtworkPrecache bool + ArtworkWorkerConcurrency int + ArtworkExternalMaxRPS int AutoImportPlaylists bool DefaultPlaylistPublicVisibility bool PlaylistsPath string @@ -346,6 +348,8 @@ func Load(noConfigDump bool) { mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") + mapDeprecatedOption("DevArtworkWorkerConcurrency", "ArtworkWorkerConcurrency") + mapDeprecatedOption("DevArtworkExternalRPS", "ArtworkExternalMaxRPS") err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -900,6 +904,8 @@ func setViperDefaults() { viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) + viper.SetDefault("artworkworkerconcurrency", 4) + viper.SetDefault("artworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/consts/consts.go b/consts/consts.go index f453ac125..0a244f55f 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -35,6 +35,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" diff --git a/core/agents/agents.go b/core/agents/agents.go index ead6dacd0..123eabb5c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -124,6 +124,50 @@ 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() { + ag := a.getAgent(ea) + if ag == nil { + continue + } + if retriever, ok := ag.(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() { + ag := a.getAgent(ea) + if ag == nil { + continue + } + if retriever, ok := ag.(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: diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 50285a084..e3087f995 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -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 +} diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go new file mode 100644 index 000000000..762c2edb5 --- /dev/null +++ b/core/artwork/agent_images.go @@ -0,0 +1,123 @@ +package artwork + +import ( + "context" + "errors" + "io" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +// externalName applies the DevPreserveUnicodeInExternalCalls normalization the aggregate +// provider used, so agent searches match the same way (typographic quotes/dashes cleared +// unless preserved). +func externalName(name string) string { + if conf.Server.DevPreserveUnicodeInExternalCalls { + return name + } + return str.Clear(name) +} + +// gateFunc gates one named external fetch (rate limit + circuit breaker per name). +// resolveItem defaults to passthroughGate; the worker injects the per-agent gate. +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() +} + +// denyGate refuses every external fetch with a definitive not-found, so local-only +// resolution never runs a network step even if an external branch is reached. +func denyGate(_ string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return nil, "", model.ErrNotFound +} + +// bestImageURL returns the largest-Size image URL, skipping empty or unparseable +// URLs; nil when none qualifies. Parsing happens per candidate so a malformed largest +// URL never shadows a valid smaller one. +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 { + 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, each under its own gate. +// Returns the winning reader + agent name; extErr is true only when NO agent succeeded and +// at least one failed transiently (a later success beats an earlier agent error). +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { + // Synthetic artists have no real external image; mirror Agents.GetArtistImages' guard so a + // direct retriever call can't assign an unrelated result to Unknown/Various Artists. + 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 // includes errBreakerOpen and download failures: retry via the next agent + } + } + 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 + } + } + return nil, "", extErr +} + +// isTransientExternal reports whether an external step failed in a way 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) +} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go new file mode 100644 index 000000000..ea58fe0ac --- /dev/null +++ b/core/artwork/agent_images_test.go @@ -0,0 +1,217 @@ +package artwork + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + + "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 +} + +func (f *fakeImageAgent) AgentName() string { return f.name } + +func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { + f.artistCalls++ + f.gotArtistName = name + 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 (ignoring the DataStore) and +// enables them in order, returning the process-wide Agents. Because the fakes ignore +// ds, reusing the 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()) + }) + }) + + 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"})) + }) + }) +}) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go deleted file mode 100644 index b8c395c12..000000000 --- a/core/artwork/artwork.go +++ /dev/null @@ -1,134 +0,0 @@ -package artwork - -import ( - "context" - "errors" - _ "image/gif" - "io" - "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" - "github.com/navidrome/navidrome/utils/cache" - _ "golang.org/x/image/webp" -) - -var ErrUnavailable = errors.New("artwork unavailable") - -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) -} - -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} -} - -type artwork struct { - ds model.DataStore - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - provider external.Provider -} - -type artworkReader interface { - cache.Item - LastUpdated() time.Time - Reader(ctx context.Context) (io.ReadCloser, string, error) -} - -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) - if err == nil { - reader, lastUpdate, err = a.Get(ctx, artID, size, square) - } - 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 reader, lastUpdate, 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 - } - - 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 - } - return r, artReader.LastUpdated(), nil -} - -type coverArtGetter interface { - CoverArtID() model.ArtworkID -} - -func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { - if id == "" { - return model.ArtworkID{}, ErrUnavailable - } - artID, err := model.ParseArtworkID(id) - if 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) - if err != nil { - return model.ArtworkID{}, err - } - if e, ok := entity.(coverArtGetter); ok { - artID = e.CoverArtID() - } - 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 -} - -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 - } - } - return artReader, err -} diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go deleted file mode 100644 index c95371959..000000000 --- a/core/artwork/artwork_internal_test.go +++ /dev/null @@ -1,628 +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{{ - 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 -} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index d42d7f3e4..ffb7d1148 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -15,9 +15,21 @@ import ( "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 +37,7 @@ 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. +// ReadTags is not exercised by these tests, 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 } diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go deleted file mode 100644 index adddd0dc3..000000000 --- a/core/artwork/artwork_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package artwork_test - -import ( - "context" - "io" - - "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/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artwork", func() { - var aw artwork.Artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg - - 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) - }) - - 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()) - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, err := io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) - - result, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - - Expect(result).To(Equal(phBytes)) - }) - }) - }) - 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)) - }) - }) - }) -}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go deleted file mode 100644 index bf3d435a8..000000000 --- a/core/artwork/benchmark_e2e_test.go +++ /dev/null @@ -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() - } - }) - } - } -} diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..302ec83c0 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,177 @@ +// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash), +// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes. +package blurhash + +import ( + "errors" + "image" + "image/draw" + "math" + "strings" + "sync" + + xdraw "golang.org/x/image/draw" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference. +const maxInputSize = 128 + +// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula). +func Components(width, height int) (int, int) { + if width <= 0 || height <= 0 { + return 0, 0 + } + 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 using xComp x yComp components. +func Encode(img image.Image, xComp, yComp int) (string, error) { + if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 { + return "", errors.New("blurhash: components must be between 1 and 9") + } + rgba := toRGBA(downscale(img)) + bounds := rgba.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w == 0 || h == 0 { + return "", errors.New("blurhash: empty image") + } + + 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 := 0; y < h; y++ { + row := rgba.Pix[y*rgba.Stride:] + for x := 0; x < w; x++ { + p := x * 4 + lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]] + for j := 0; j < yComp; j++ { + for i := 0; i < xComp; i++ { + 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)) + + ac := factors[1:] + maxVal := 1.0 + if len(ac) > 0 { + actualMax := 0.0 + for _, f := range ac { + actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) + } + quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5)))) + maxVal = float64(quantMax+1) / 166 + sb.WriteString(Encode83(quantMax, 1)) + } else { + sb.WriteString(Encode83(0, 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 +} + +// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the +// image.At interface (~16k allocs per encode). +func toRGBA(img image.Image) *image.RGBA { + if rgba, ok := img.(*image.RGBA); ok { + return rgba + } + b := img.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +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(math.Max(0, math.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 = math.Min(math.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, using the +// blurhash spec's alphabet. +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) +} diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go new file mode 100644 index 000000000..43b593eb5 --- /dev/null +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -0,0 +1,41 @@ +package blurhash_test + +import ( + "fmt" + "image" + "image/color" + "testing" + + "github.com/navidrome/navidrome/core/artwork/blurhash" +) + +// benchImage builds a deterministic gradient so runs are comparable across revisions. +func benchImage(size int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.SetNRGBA(x, y, color.NRGBA{ + R: uint8(255 * x / size), + G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), + A: 255, + }) + } + } + return img +} + +func BenchmarkEncode(b *testing.B) { + for _, size := range []int{100, 300, 600, 900, 1200, 1500} { + img := benchImage(size) + x, y := blurhash.Components(size, size) + b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := blurhash.Encode(img, x, y); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -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") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..18dd86b99 --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -0,0 +1,113 @@ +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 := 0; y < h; y++ { + for x := 0; x < w; x++ { + 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 := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + return img +} + +var _ = Describe("Components", func() { + DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", + func(w, h, expectedX, expectedY int) { + x, y := blurhash.Components(w, h) + Expect(x).To(Equal(expectedX)) + Expect(y).To(Equal(expectedY)) + }, + Entry("square album art", 600, 600, 5, 5), + Entry("small square", 1, 1, 5, 5), + Entry("landscape 16:9", 1920, 1080, 6, 4), + Entry("portrait 9:16", 1080, 1920, 4, 6), + Entry("extreme landscape capped at 9", 10000, 100, 9, 1), + Entry("zero width", 0, 600, 0, 0), + Entry("zero height", 600, 0, 0, 0), + ) +}) + +var _ = Describe("Encode", func() { + It("rejects out-of-range components", func() { + _, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5) + Expect(err).To(HaveOccurred()) + _, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10) + Expect(err).To(HaveOccurred()) + }) + + It("produces the spec-mandated length", func() { + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1))) + }) + + It("encodes the size flag as the first character", func() { + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9)) + }) + + 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}), 4, 3) + 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, 5, 5) + h2, err2 := blurhash.Encode(img, 5, 5) + 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}), 4, 4) + h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4) + Expect(h1).ToNot(Equal(h2)) + }) + + It("downscales large images internally without changing the result materially", func() { + // A 1000px solid image must encode fine and carry the same DC as its small version. + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(big[2:6]).To(Equal(small[2:6])) + }) +}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go deleted file mode 100644 index 5090d638e..000000000 --- a/core/artwork/cache_warmer.go +++ /dev/null @@ -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) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go deleted file mode 100644 index a5da2004c..000000000 --- a/core/artwork/cache_warmer_test.go +++ /dev/null @@ -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) -} diff --git a/core/artwork/reader_disc.go b/core/artwork/disc.go similarity index 79% rename from core/artwork/reader_disc.go rename to core/artwork/disc.go index 0f648c987..8bc566af2 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/disc.go @@ -2,26 +2,22 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "path" "path/filepath" "strconv" "strings" - "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" ) +// 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 +25,26 @@ type discArtworkReader struct { isMultiFolder bool firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs lib libraryView - updatedAt *time.Time } -func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { +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, _, err := loadAlbumFoldersPaths(ctx, ds, *al) if err != nil { return nil, err } // 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,7 +53,7 @@ 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 } @@ -80,7 +75,7 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID for id := range allFolderIDs { folderIDs = append(folderIDs, id) } - folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"folder.id": folderIDs}, }) if err != nil { @@ -92,46 +87,15 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID } } - isMultiFolder := len(al.FolderIDs) > 1 - - r := &discArtworkReader{ - a: a, + return &discArtworkReader{ album: *al, discNumber: discNumber, imgFiles: imgFiles, discFoldersRel: discFoldersRel, - isMultiFolder: isMultiFolder, + isMultiFolder: len(al.FolderIDs) > 1, 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...) + }, nil } func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { diff --git a/core/artwork/reader_disc_test.go b/core/artwork/disc_test.go similarity index 100% rename from core/artwork/reader_disc_test.go rename to core/artwork/disc_test.go diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go new file mode 100644 index 000000000..3a20a0245 --- /dev/null +++ b/core/artwork/e2e/acquire_serve_test.go @@ -0,0 +1,226 @@ +package e2e + +import ( + "context" + "errors" + "io" + "os" + + "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" +) + +// These specs wire the real Worker and Service over the same ImageStore and mock repositories, +// then drive the full enqueue → drain → serve loop. They assert the integration of the chain, not +// the per-source resolution rules (which the unit suites in package artwork already cover). +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.Service + worker *artwork.Worker + coverBytes []byte + ) + + // itemFound reports whether the worker has persisted a resolved (hash-bearing) state row. + itemFound := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash != "" + } + } + itemAbsent := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash == "" + } + } + + 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" // upload wins first; kept offline as a safety net + conf.Server.EnableMediaFileCoverArt = true + conf.Server.ArtworkWorkerConcurrency = 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 and never touch the resize cache, so this reader is a + // compile-time stand-in only; the resize path is covered by the package's serving_test. + 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.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + }) + + // seedFolderAlbum wires an album backed by the real fixture folder cover, shared by the album + // and playlist-grid scenarios. + 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") + worker.Bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound("al", "al1")) + + ia, err := artRepo.GetItemArtwork("al", "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}}) + worker.Bump("ar", "ar1") + runWorkerUntil(ctx, worker, itemFound("ar", "ar1")) + + ia, err := artRepo.GetItemArtwork("ar", "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"}} + worker.Bump("pl", "pl1") + runWorkerUntil(ctx, worker, itemFound("pl", "pl1")) + + ia, err := artRepo.GetItemArtwork("pl", "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)) + // The generated grid is a fresh PNG placed in the content-addressed store. + 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} + worker.Bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound("ra", "ra1")) + + ia, err := artRepo.GetItemArtwork("ra", "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, + }}) + + // First read: no state row yet → extract embedded art provisionally and enqueue the track. + 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("mf", "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 and confirm the persisted hash matches. + runWorkerUntil(ctx, worker, itemFound("mf", "mf1")) + ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).To(Equal(provisional.Hash)) + + // Second read: now served from the persisted state row / store, same bytes. + 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("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}}) + worker.Bump("al", "alx") + runWorkerUntil(ctx, worker, itemAbsent("al", "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()) + }) +}) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go deleted file mode 100644 index 5e61684cc..000000000 --- a/core/artwork/e2e/album_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package artworke2e_test - -import ( - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - -var _ = Describe("Album artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("an album has a single folder with cover.jpg at the album root", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← matched by cover.* - It("returns the album-root cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // cover.* basenames tie across album-root and per-disc folders; - // compareImageFiles must prefer shallower paths. - When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← should not win - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg - // └── cover.jpg ← should win (album-root fallback) - It("prefers the album-root cover over per-disc covers", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - 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"), - }) - 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"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // folder.jpg basenames tie across album-root and per-disc folders; - // compareImageFiles must prefer shallower paths. - When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← should not win - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── folder.jpg ← should win (album-root fallback) - It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // Single-subfolder albums must still consider the parent folder's images. - When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { - // Artist/ - // └── Album/ - // ├── disc1/ - // │ └── 01 - Track.mp3 - // └── cover.jpg ← should win (parent-folder fallback) - It("uses the parent-folder cover for single-disc-subfolder albums", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5456 - When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { - // Album/ (top-level folder, Path=".") - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── cover.jpg ← should win (album-root) - It("prefers the album-root cover.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") - // └── cover.jpg - It("returns the embedded image", 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"), - }) - 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)) - }) - }) - - When("CoverArtPriority lists external first but no external file is present", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") - It("falls through to embedded artwork", func() { - conf.Server.CoverArtPriority = "external, embedded" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) - }) - }) - - When("the only cover file uses uppercase extension and a different case in its name", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── Cover.JPG ← matched case-insensitively by cover.* - It("matches case-insensitively against cover.*", func() { - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/Cover.JPG": imageFile("case-insensitive"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) - }) - }) - - When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (no numeric suffix) - // └── cover.1.jpg - It("prefers the file without a numeric suffix", 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) - }) - }) - - 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() { - 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()) - }) - }) - - // Doc scenarios from: - // https://www.navidrome.org/docs/usage/library/artwork/#albums - // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". - When("only folder.jpg is present (cover.* and front.* missing)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── folder.jpg ← matched by folder.* - It("falls through to folder.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) - }) - }) - - When("only front.jpg is present (cover.* and folder.* missing)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── front.jpg ← matched by front.* - It("falls through to front.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/front.jpg": imageFile("front"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) - }) - }) - - When("cover.*, folder.*, and front.* all exist in the same folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (cover.* is first in priority) - // ├── folder.jpg - // └── front.jpg - It("prefers cover.* (first in CoverArtPriority)", 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) - - When("only folder.* and front.* exist (priority order check)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── folder.jpg ← wins (folder.* comes before front.*) - // └── front.jpg - It("prefers folder.* over front.*", 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) - }) - }) - - When("three cover files tie by basename and differ only by numeric suffix", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (no numeric suffix) - // ├── cover.1.jpg - // └── cover.2.jpg - It("selects the unsuffixed file first regardless of numeric-suffix order", 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"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) - }) - }) - - When("CoverArtPriority contains an unknown pattern before a matching one", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← wins (unknown "bogus.*" is skipped) - It("skips the unknown pattern and falls through to the matching one", func() { - conf.Server.CoverArtPriority = "bogus.*, cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) - - // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder - // fallback can pick up images from the ARTIST folder, serving the artist - // thumbnail as album art for any album without its own image files. - When("an album has no images and the artist folder has folder.jpg", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (no images) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - 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/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"), - }) - 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"))) - }) - }) - - When("a single-disc album is spread across sibling folders under the artist folder", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (album: "Album A") - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - 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/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"), - }) - 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") - }) - }) - - When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { - // Artist/ - // ├── cover.jpg ← artist image; matches cover.* (first pattern), - // │ must NOT shadow the album's own front.jpg - // ├── Album A/ - // │ ├── 01 - Track.mp3 (album: "Album A") - // │ └── front.jpg ← should win - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A") - // └── Album B/ - // └── 01 - Track.mp3 - 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/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 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"}), - }) - scan() - - 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"))) - }) - }) - - When("embedded is first in CoverArtPriority but the track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (no embedded picture) - // └── cover.jpg ← wins (embedded skipped, falls through) - It("falls through to the next priority entry", func() { - conf.Server.CoverArtPriority = "embedded, cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) -}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go deleted file mode 100644 index d959b1d60..000000000 --- a/core/artwork/e2e/artist_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package artworke2e_test - -import ( - "os" - "path/filepath" - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Doc reference: -// https://www.navidrome.org/docs/usage/library/artwork/#artists -// Default ArtistArtPriority is "artist.*, album/artist.*, external". -var _ = Describe("Artist artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("the artist folder contains an artist.jpg", func() { - // Artist/ - // ├── artist.jpg ← matched by artist.* - // └── Album/ - // └── 01 - Track.mp3 - It("returns the artist.* image from the artist folder", 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"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) - }) - }) - - When("artist.* only exists inside an album folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg ← matched by album/artist.* - It("falls through to album/artist.* and returns that image", 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"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) - }) - }) - - When("both the artist folder and an album folder have an artist.* image", func() { - // Artist/ - // ├── artist.jpg ← wins (artist.* before album/artist.*) - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg - It("prefers the artist-folder image (artist.* comes before album/artist.*)", 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"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) - }) - }) - - When("an artist has an uploaded image and a matching artist.* file", func() { - // / - // └── artwork/ - // └── artist/ - // └── _upload.jpg ← wins (uploaded image beats the priority chain) - // Library: - // Artist/ - // ├── artist.jpg (ignored — uploaded image comes first) - // └── Album/ - // └── 01 - Track.mp3 - It("prefers the uploaded image over any priority-chain match", 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"), - }) - scan() - ar := soleArtist() - - uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) - ar.UploadedImage = uploaded - Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) - - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) - }) - }) - - When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg ← matched by album/artist.* - It("resolves the pattern against the artist's album image files", 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"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) - }) - }) - - When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { - // / - // └── Artist.jpg ← matched by artist name (image-folder source) - // Library: - // Artist/ - // └── Album/ - // └── 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()) - conf.Server.ArtistImageFolder = imgFolder - conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" - - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) - }) - }) -}) - -func soleArtist() model.Artist { - GinkgoHelper() - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"artist.name": "Artist"}, - }) - Expect(err).ToNot(HaveOccurred()) - if len(artists) == 0 { - Fail("sole artist not found") - return model.Artist{} - } - return artists[0] -} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go deleted file mode 100644 index 667079458..000000000 --- a/core/artwork/e2e/disc_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package artworke2e_test - -import ( - "fmt" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Disc artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("the album is single-disc with a disc1.jpg in the only folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── disc1.jpg ← matched by disc*.* - It("returns the disc1.jpg image (matched as disc*.*)", 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("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() { - 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()) - }) - }) - - When("the album has no per-disc image but has an album cover", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← album-level fallback (no disc art present) - It("falls back to the album cover", func() { - conf.Server.DiscArtPriority = "disc*.*, cd*.*" - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("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"))) - }) - }) - - When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── disc1.jpg ← matches request for disc 1 - // └── disc10.jpg - It("matches the requested disc number, not a higher-numbered one", 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) - }) - }) - - When("a multi-disc album has per-disc covers", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg ← matches request for disc 1 - // └── CD2/ - // ├── 01 - Track.mp3 - // └── disc2.jpg ← matches request for disc 2 - It("returns the requested disc's image", func() { - conf.Server.DiscArtPriority = "disc*.*" - 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) - }) - }) - - // Doc scenarios from: - // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art - // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". - When("a disc subfolder has a cd2.png image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg - // └── CD2/ - // ├── 01 - Track.mp3 - // └── cd2.png ← matched by cd*.* for disc 2 - It("matches via the cd*.* pattern", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) - }) - }) - - When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← matched by cover.* inside disc folder - // └── CD2/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("falls through to cover.* inside the disc folder", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) - }) - }) - - When("DiscArtPriority is the empty string", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg (ignored — DiscArtPriority is empty) - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── cd2.png (ignored — DiscArtPriority is empty) - // └── cover.jpg ← used for every disc (album-level fallback) - It("skips every disc-level source and returns the album cover", func() { - conf.Server.DiscArtPriority = "" - conf.Server.CoverArtPriority = defaultCoverPriority - 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"), - }) - 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) - } - }) - }) - - When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { - // Artist/ - // └── Album/ - // ├── disc1/ - // │ ├── disc1.jpg ← matched by disc*.* for disc 1 - // │ ├── 01 - Track.mp3 - // │ └── 02 - Track.mp3 - // ├── disc2/ - // │ ├── cd2.png ← matched by cd*.* for disc 2 - // │ ├── 01 - Track.mp3 - // │ └── 02 - Track.mp3 - // └── cover.jpg (album-level fallback, unused here) - It("matches the per-disc image for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "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"), - }) - 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"))) - }) - }) - - When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") - // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword - It("selects the subtitle-named image", 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) - }) - }) - - // Reproduces https://github.com/navidrome/navidrome/issues/5456 - // Deeply nested layout matching the reporter's actual structure. - When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { - // Genre/Artist/Album/ ← album root with cover.jpg - // ├── cover.jpg ← album-level cover - // ├── Disc 01 (Subtitle)/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← disc 1 art - // ├── Disc 02 (Subtitle)/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── ... (12 discs) - It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - discNames := []string{ - "Disc 01 (Birth of the Dead - The Studio Sides)", - "Disc 02 (Birth of the Dead - The Live Sides)", - "Disc 03 (The Grateful Dead)", - "Disc 04 (Anthem of the Sun)", - "Disc 05 (Aoxomoxoa)", - "Disc 06 (Live; Dead)", - "Disc 07 (Workingman's Dead)", - "Disc 08 (American Beauty)", - "Disc 09 (Grateful Dead)", - "Disc 10 (Europe '72)", - "Disc 11 (Europe '72)", - "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"), - } - 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)) - } - setLayout(layout) - scan() - - al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - - 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) - } - }) - }) - - // https://github.com/navidrome/navidrome/issues/5456 - // Top-level album variant — album folder at library root (Path="."). - When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { - // Album/ (top-level, Path=".") - // ├── cover.jpg ← album-level cover - // ├── Disc 01/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← disc 1 art - // ├── Disc 02/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── Disc 03/ - // ├── 01 - Track.mp3 - // └── folder.jpg - It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - layout := fstest.MapFS{ - "Album/cover.jpg": imageFile("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)) - } - setLayout(layout) - scan() - - al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - - 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) - } - }) - }) - - When("discsubtitle is set but no image filename matches the subtitle", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") - // └── cover.jpg ← wins (discsubtitle has no match, falls through) - It("falls through to the next priority entry", 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"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) - }) - }) -}) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go new file mode 100644 index 000000000..bec7b12cf --- /dev/null +++ b/core/artwork/e2e/e2e_suite_test.go @@ -0,0 +1,92 @@ +// Package e2e exercises the artwork pipeline end to end: it enqueues real entities, drives the +// real Worker to drain the queue, and serves the result through the real Service, 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" +) + +// readFixture returns the raw bytes of a project-relative fixture file. +func readFixture(rel string) []byte { + GinkgoHelper() + data, err := os.ReadFile(rel) + Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) + return data +} + +// readAll drains an artwork image to bytes and closes it. +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 +} + +// runWorkerUntil starts the real worker loop, waits for a condition, then cancels and joins it, +// mirroring how cmd drives Worker.Run in production. +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())) +} + +// fakeFolderRepo is the minimal FolderRepository the album/playlist resolution chains touch: +// GetAll yields the seeded folders and the album-root parent lookup finds nothing. +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 } + +// writeUpload copies a fixture into the per-entity upload folder under the data dir and returns +// the bare filename UploadedImagePath expects. +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 +} diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go deleted file mode 100644 index e3abca097..000000000 --- a/core/artwork/e2e/helpers_test.go +++ /dev/null @@ -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 /artwork// 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, 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) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go deleted file mode 100644 index 1f43a3827..000000000 --- a/core/artwork/e2e/mediafile_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package artworke2e_test - -import ( - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Doc reference: -// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles -// Navidrome resolves mediafile artwork in this order: -// 1. Embedded image from the mediafile itself -// 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() { - BeforeEach(func() { - setupHarness() - }) - - When("a multi-disc album track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg - // ├── CD2/ - // │ ├── 01 - Track.mp3 ← track requested - // │ └── disc2.jpg ← wins (disc-level before album-level) - // └── cover.jpg - It("falls back to the disc-level artwork (not the album cover)", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - 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"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) - }) - }) - - When("a single-disc album track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← track requested - // └── cover.jpg ← wins (album-level fallback, no disc subfolder) - It("falls back to the album cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), - }) - scan() - - mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) - }) - }) - - When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ └── 01 - Track.mp3 - // ├── CD2/ - // │ └── 01 - Track.mp3 ← track requested - // └── cover.jpg ← wins (no disc image → album-level fallback) - It("falls through from disc to album cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - 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"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) -}) - -func mediafileOn(relPath string) model.MediaFile { - GinkgoHelper() - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Like{"media_file.path": relPath}, - }) - Expect(err).ToNot(HaveOccurred()) - if len(mfs) == 0 { - Fail("mediafile not found: " + relPath) - return model.MediaFile{} - } - return mfs[0] -} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go deleted file mode 100644 index d28efca8e..000000000 --- a/core/artwork/e2e/playlist_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package artworke2e_test - -import ( - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Playlist artwork resolves in this priority order: -// 1. Uploaded image (/artwork/playlist/) -// 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 -// -// 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. -var _ = Describe("Playlist artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("a playlist has an uploaded image", func() { - // / - // └── artwork/ - // └── 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")) - - pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) - }) - }) - - When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { - // / - // ├── MyList.m3u - // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) - 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()) - - pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) - }) - }) - - When("a playlist's sidecar uses a different extension case", func() { - // / - // ├── MyList.m3u - // └── MyList.PNG ← matched case-insensitively - 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()) - - pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) - }) - }) - - When("a playlist has an ExternalImageURL pointing to a local file", func() { - // / - // └── cover.jpg ← absolute path stored in ExternalImageURL - It("returns the local file regardless of EnableM3UExternalAlbumArt", 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()) - - pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("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() { - 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())) - }) - }) - - 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() { - pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) - }) - }) - - When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { - // Library: - // Artist/ - // ├── AlbumA/ - // │ ├── 01 - Track.mp3 - // │ └── cover.png (real PNG — wins as 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). - 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/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": realPNG("albumB"), - }) - scan() - - // Pull the scanned mediafile IDs so we can attach them to the playlist. - mfs, err := ds.MediaFile(ctx).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()) - - 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. - Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - Expect(len(data)).To(BeNumerically(">", 1000)) - }) - }) -}) - -func putPlaylist(pl model.Playlist) model.Playlist { - GinkgoHelper() - if pl.OwnerID == "" { - pl.OwnerID = "admin-1" - } - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) - return pl -} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go deleted file mode 100644 index 73ee5f377..000000000 --- a/core/artwork/e2e/radio_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package artworke2e_test - -import ( - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Radio artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("a radio has an uploaded image", func() { - // / - // └── artwork/ - // └── 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")) - - 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()) - - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("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() { - rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) - - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - _, err := readArtworkOrErr(artID) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go deleted file mode 100644 index 06cc05b6f..000000000 --- a/core/artwork/e2e/suite_test.go +++ /dev/null @@ -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{} -} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 deleted file mode 100644 index 18cb90674..000000000 Binary files a/core/artwork/e2e/testdata/embedded_art.mp3 and /dev/null differ diff --git a/core/artwork/reader_album.go b/core/artwork/folders_album.go similarity index 63% rename from core/artwork/reader_album.go rename to core/artwork/folders_album.go index 8ad07773b..d5abee2ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/folders_album.go @@ -3,106 +3,18 @@ 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" ) -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, albums ...model.Album) ([]string, []string, *time.Time, error) { var folderIDs []string for _, album := range albums { diff --git a/core/artwork/reader_artist.go b/core/artwork/folders_artist.go similarity index 52% rename from core/artwork/reader_artist.go rename to core/artwork/folders_artist.go index 37b7b6dee..b75cddde6 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/folders_artist.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "io/fs" @@ -14,9 +13,7 @@ import ( "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/str" @@ -28,127 +25,6 @@ const ( 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 := loadAlbumFoldersPaths(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 { @@ -262,29 +138,6 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu 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 { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go new file mode 100644 index 000000000..f45c62ab6 --- /dev/null +++ b/core/artwork/housekeeping.go @@ -0,0 +1,102 @@ +package artwork + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "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" +) + +// FingerprintPropertyKey is the model.PropertyRepository key Backfill compares against +// to detect artwork-affecting config changes across restarts. +const FingerprintPropertyKey = "artwork.fingerprint" + +// staleAbsentAge is how old an absent resolution must be before the recheck job retries it. +const staleAbsentAge = 24 * time.Hour + +// staleAbsentKinds are the item kinds eligible for the periodic stale-absent recheck. +var staleAbsentKinds = []string{"ar", "al", "pl", "ra"} + +// Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a +// change means previously resolved (or absent) state may no longer be correct. +func Fingerprint() string { + raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%s", + conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, + conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, consts.Version) + 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 +// (or was never stored), artists first so those pages resolve before the larger backlog. +func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { + ctx = auth.WithAdminUser(ctx, ds) + current := Fingerprint() + props := ds.Property(ctx) + stored, err := props.DefaultGet(FingerprintPropertyKey, "") + if err != nil { + return false, err + } + if stored == current { + return false, nil + } + + // Artists first: few entities, most external-dependent, so they get queue headstart. + kinds := []struct { + kind string + fetch func() ([]string, error) + }{ + {"ar", func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, + {"al", func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, + {"pl", func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, + {"ra", 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(FingerprintPropertyKey, current); err != nil { + return false, err + } + log.Info(ctx, "Artwork: config fingerprint changed, backfill enqueued") + return true, nil +} + +func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind string, ids []string) error { + if len(ids) == 0 { + return nil + } + items := make([]model.ArtworkQueueItem, len(ids)) + for i, id := range ids { + items[i] = model.ArtworkQueueItem{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, + } + } + return ds.ArtworkQueue(ctx).Enqueue(items...) +} + +// EnqueueStaleAbsentAll requeues absent-state entries older than staleAbsentAge, across +// every artwork-bearing kind, for the periodic recheck job. +func EnqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { + cutoff := time.Now().Add(-staleAbsentAge) + queue := ds.ArtworkQueue(ctx) + for _, kind := range staleAbsentKinds { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { + return err + } + } + return nil +} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go new file mode 100644 index 000000000..1df8312e8 --- /dev/null +++ b/core/artwork/housekeeping_test.go @@ -0,0 +1,226 @@ +package artwork + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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)) + }) + }) + + Describe("Backfill", func() { + It("enqueues nothing and returns false when the stored fingerprint matches", func() { + seedEntities() + Expect(propRepo.Put(FingerprintPropertyKey, 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(FingerprintPropertyKey) + 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(FingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + did, err := Backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + Expect(queueRepo.callKinds).ToNot(BeEmpty()) + artistCallIdx := -1 + for i, k := range queueRepo.callKinds { + if k == "ar" { + artistCallIdx = i + break + } + } + Expect(artistCallIdx).To(Equal(0), "artists must be the first Enqueue call") + for i, k := range queueRepo.callKinds { + if k != "ar" { + Expect(i).To(BeNumerically(">", artistCallIdx)) + } + } + + 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} + // Not stale: too recent. + artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} + // Not absent: has a resolved hash. + 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()) + }) + }) +}) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index ac0f63794..7d73b1bae 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -2,29 +2,21 @@ package artwork import ( "context" - "fmt" "io" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" "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 + LastUpdated() time.Time + Reader(ctx context.Context) (io.ReadCloser, string, error) } type imageCache struct { diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go new file mode 100644 index 000000000..53662f686 --- /dev/null +++ b/core/artwork/image_store.go @@ -0,0 +1,172 @@ +package artwork + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/zeebo/xxh3" +) + +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 +} + +// ImageStore is the content-addressed store for artwork images that have no +// library file backing them (external downloads, embedded extractions, generated). +type ImageStore struct { + root string +} + +func NewImageStore(rootDir string) *ImageStore { + return &ImageStore{root: rootDir} +} + +// ProvideImageStore roots the store in its own subtree under the data folder, so +// Prune's recursive sweep never reaches the per-entity upload folders next to it. +func ProvideImageStore() *ImageStore { + return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, "store")) +} + +// extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into +// content-addressed paths and re-derived on Open, so they must be stable across OSes. +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" +} + +// validHash rejects anything but 16 lowercase hex chars: known-absent states carry "", +// and malformed persisted hashes must never reach path sharding (slice panics, 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 (file likely pruned concurrently) — fall through and write 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)) +} + +// Remove deletes the store file unless it is newer than olderThan, in which case +// an overlapping acquisition may have just touched it and be about to commit its row. +func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } + path := s.path(hash, mimeType) + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.ModTime().After(olderThan) { + return nil + } + err = os.Remove(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +// Sweep removes store files not accepted by keep. Files modified after cutoff +// (including temp files) are always kept: their acquisition row may not be committed yet. +func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash, ext string) bool) (int, error) { + removed := 0 + err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + 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 { + return err + } + removed++ + } + return nil + }) + if errors.Is(err, fs.ErrNotExist) { + return removed, nil + } + return removed, err +} diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go new file mode 100644 index 000000000..6537ca906 --- /dev/null +++ b/core/artwork/image_store_test.go @@ -0,0 +1,204 @@ +package artwork + +import ( + "bytes" + "io" + "os" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageStore", func() { + var store *ImageStore + var root string + + BeforeEach(func() { + root = GinkgoT().TempDir() + store = NewImageStore(root) + }) + + 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("removes without error when already gone", func() { + Expect(store.Remove("beefbeefbeefbeef", "image/jpeg", time.Now())).To(Succeed()) + }) + + 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"))) + Expect(store.Remove(h, "image/jpeg", time.Now())).To(MatchError(ContainSubstring("invalid hash"))) + } + }) + + It("spares a file newer than the cutoff, removes an aged one", func() { + fresh := []byte("fresh") + hf, _ := HashImage(bytes.NewReader(fresh)) + Expect(store.Write(hf, "image/jpeg", bytes.NewReader(fresh))).To(Succeed()) + + aged := []byte("aged") + ha, _ := HashImage(bytes.NewReader(aged)) + Expect(store.Write(ha, "image/jpeg", bytes.NewReader(aged))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(ha, "image/jpeg"), old, old)).To(Succeed()) + + cutoff := time.Now().Add(-time.Hour) + Expect(store.Remove(hf, "image/jpeg", cutoff)).To(Succeed()) + Expect(store.Remove(ha, "image/jpeg", cutoff)).To(Succeed()) + + rc, err := store.Open(hf, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + _, err = store.Open(ha, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + 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(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(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(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(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()) + }) +}) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index ff557294e..6e6099325 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -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 } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index acf08fda3..22498e7a1 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -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()) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go new file mode 100644 index 000000000..4373e2007 --- /dev/null +++ b/core/artwork/playlist_cover.go @@ -0,0 +1,86 @@ +package artwork + +import ( + "context" + "image" + "image/draw" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +const tileSize = 600 + +// 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 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 +} diff --git a/core/artwork/processor.go b/core/artwork/processor.go new file mode 100644 index 000000000..4272d41b8 --- /dev/null +++ b/core/artwork/processor.go @@ -0,0 +1,248 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/draw" + "io" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + xdraw "golang.org/x/image/draw" +) + +// outcome tells the worker what to do with the queue row: found/absent +// delete it, failed reschedules it via MarkFailed. +type outcome int + +const ( + outcomeFound outcome = iota + // outcomeFoundStale: state was written and is served, but a higher-priority external + // step failed, so the row must retry (via MarkFailed) to give that source another chance. + outcomeFoundStale + outcomeAbsent + outcomeFailed +) + +// thumbnailSize is the max dimension fed to blurhash. +const thumbnailSize = 128 + +// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could +// point at an arbitrarily large endpoint, and 20MB is generous for any real cover. +const maxImageBytes = 20 << 20 + +// maxImagePixels caps declared dimensions: a tiny compressed file can declare a +// huge canvas that image.Decode would expand into gigabytes (decompression bomb). +const maxImagePixels = 64 << 20 + +// workerDeps are the collaborators processItem needs; gate is set by NewWorker in +// production and nil only in tests, where resolveItem falls back to a plain passthrough. +type workerDeps struct { + ds model.DataStore + store *ImageStore + agents *agents.Agents + ffmpeg ffmpeg.FFmpeg + cache cache.FileCache + gate gateFunc +} + +// processItem resolves one queue item end to end: find an image, hash/decode/ +// blurhash it, place its bytes, and persist the resulting state. +func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueItem) outcome { + repo := deps.ds.Artwork(ctx) + + res, err := resolveItem(ctx, deps.ds, deps.agents, deps.ffmpeg, item, deps.gate) + if err != nil { + log.Warn(ctx, "artwork: could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if res.reader == nil { + if res.extError { + // An external source errored/timed out: never settle on absent, keep serving old state. + return outcomeFailed + } + return writeAbsent(ctx, repo, item) + } + defer res.reader.Close() + + 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 + } + log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) + + 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 + } + + art, err := repo.GetImage(hash) + switch { + case err == nil: + // Dedup hit: identical bytes already known, reuse dims/mime/blurhash. + case errors.Is(err, model.ErrNotFound): + 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 + } + default: + log.Warn(ctx, "artwork: failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + art.SizeBytes = int64(len(data)) + + sourcePath, refMtime, err := placeBytes(deps.store, art, res, data) + if err != nil { + log.Warn(ctx, "artwork: failed to write image store", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if err := repo.PutImage(art); err != nil { + log.Warn(ctx, "artwork: failed to persist artwork image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if err := repo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + Hash: hash, + Source: res.source, + SourcePath: sourcePath, + RefMtime: refMtime, + AttemptedAt: time.Now(), + }); err != nil { + log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if res.extError { + return outcomeFoundStale + } + return outcomeFound +} + +// writeAbsent records a known-absent state: every local/external 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 + } + return outcomeAbsent +} + +// readCapped reads r, rejecting anything over maxImageBytes. +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, then decodes. +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) + } + if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { + 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 a +// blurhash computed from a 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) + xComp, yComp := blurhash.Components(thumb.Bounds().Dx(), thumb.Bounds().Dy()) + bh, err := blurhash.Encode(thumb, xComp, yComp) + if err != nil { + log.Warn(ctx, "artwork: blurhash encoding failed", "hash", hash, err) + bh = "" + } + + return &model.Artwork{ + Hash: hash, + Mime: mimeForFormat(format), + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), + BlurHash: bh, + }, nil +} + +// makeThumbnail downscales img to fit within maxSize on its longest side. +// Images within bounds are returned as-is (no upscaling). +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)) + dst := image.NewRGBA(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 a resolution's bytes already live in a library/upload +// file, so the acquisition must not duplicate them into the content-addressed store. +func isFileBacked(source string) bool { + return source == "folder" || source == "upload" +} + +// placeBytes reports the item's backing-file provenance (folder/upload: image, embedded: audio, +// external/generated: none) and writes the bytes into the store for the non-file-backed sources. +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 +// in image_store.go performs the inverse for content-addressed file paths. +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" +} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go new file mode 100644 index 000000000..3d145ff71 --- /dev/null +++ b/core/artwork/processor_test.go @@ -0,0 +1,351 @@ +package artwork + +import ( + "context" + "encoding/binary" + "errors" + "hash/crc32" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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" +) + +// pngHeaderWithDims builds just a PNG signature + IHDR chunk declaring w×h. DecodeConfig +// reads the header without touching pixel data, so the body 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("processItem", 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 + deps *workerDeps + ) + + 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()) + deps = &workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffm} + + 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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "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()) + _, err = store.Open(ia.Hash, art.Mime) + Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") + }) + + 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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(out).To(Equal(outcomeAbsent)) + + ia, err := artRepo.GetItemArtwork("al", "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-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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("al", "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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + Expect(out).To(Equal(outcomeFoundStale)) + + ia, err := artRepo.GetItemArtwork("al", "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: 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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "alext", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("external:deezerFake")) + Expect(ia.Hash).ToNot(BeEmpty()) + + // External art is content-addressed into the store, not file-backed. + 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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(out1).To(Equal(outcomeFound)) + ia1, err := artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + + // Poison the stored blurhash: if the second item re-decodes instead of + // deduping on hash, this sentinel gets overwritten by a real computed value. + poisoned := artRepo.Data[ia1.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[ia1.Hash] = poisoned + + out2 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(out2).To(Equal(outcomeFound)) + ia2, err := artRepo.GetItemArtwork("al", "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() { + // Two distinct library files with byte-identical content resolve to the same + // hash. Provenance is per-item, so neither file's path may overwrite the other. + 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"}}} + Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})).To(Equal(outcomeFound)) + iaA, err := artRepo.GetItemArtwork("al", "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())) + + // Poison the shared row's blurhash: the second item must dedup on hash, not re-decode. + poisoned := artRepo.Data[iaA.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[iaA.Hash] = poisoned + + folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} + Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})).To(Equal(outcomeFound)) + iaB, err := artRepo.GetItemArtwork("al", "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())) + + // The first item's provenance survives the second item processing identical bytes. + iaAafter, err := artRepo.GetItemArtwork("al", "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())) + + // One shared artwork row, and dedup preserved it untouched. + 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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("ra", "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 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err = artRepo.GetItemArtwork("ra", "big", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + 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()) + deps.store = NewImageStore(blockedRoot) + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/core/artwork/prune.go b/core/artwork/prune.go new file mode 100644 index 000000000..35eddb525 --- /dev/null +++ b/core/artwork/prune.go @@ -0,0 +1,86 @@ +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 { + repo := ds.Artwork(ctx) + + purged, err := repo.PurgeDanglingItemArtwork() + if err != nil { + return err + } + if purged > 0 { + log.Info(ctx, "Prune: purged dangling item artwork 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, "Prune: purged dangling artwork queue rows", "count", queuePurged) + } + + // One grace cutoff for both the DB orphan check and the file sweep: files younger + // than the window may belong to acquisitions whose rows aren't committed yet. + cutoff := time.Now().Add(-pruneMinAge) + candidates, err := repo.GetOrphanHashes(cutoff) + if err != nil { + return err + } + if len(candidates) > 0 { + arts, err := repo.GetImages(candidates) + if err != nil { + return err + } + if err := repo.DeleteOrphans(cutoff, candidates); err != nil { + return err + } + // DeleteOrphans may spare candidates reacquired since the snapshot; only remove files + // for rows actually gone (absent from the post-delete re-read). + survivors, err := repo.GetImages(candidates) + if err != nil { + return err + } + removed := 0 + for _, h := range candidates { + if _, ok := survivors[h]; ok { + continue + } + // A spared fresh file is at worst a stray a later sweep reclaims; + // Worker.RunPrune serializes prune against in-flight acquisitions. + if err := store.Remove(h, arts[h].Mime, cutoff); err != nil { + log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err) + } + removed++ + } + log.Info(ctx, "Prune: removed orphan artwork", "count", removed) + } + + mimes, err := repo.GetAllMimes() + if err != nil { + return err + } + removed, err := store.Sweep(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, "Prune: swept stray artwork files", "count", removed) + } + return nil +} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go new file mode 100644 index 000000000..646ad7440 --- /dev/null +++ b/core/artwork/prune_test.go @@ -0,0 +1,254 @@ +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) GetAllMimes() (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("al", "gone-album", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork("ar", "gone-artist", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork("ar", "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) + awRepo.OrphanHashes = []string{h} + + 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 a candidate reacquired between snapshot and delete", 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)) + awRepo.OrphanHashes = []string{h} + // Reacquisition: an item now references the hash the snapshot flagged as orphan. + 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 a candidate whose row was freshly recreated (created_at 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 after the snapshot; still unreferenced. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + awRepo.OrphanHashes = []string{h} + + 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)) + awRepo.OrphanHashes = []string{h} + // The row is legitimately orphaned, but a concurrent acquisition just touched the + // file's mtime (duplicate Write) and is about to commit a row referencing it. + + 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("warns and continues past a store.Remove failure instead of aborting the loop", 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(os.Chmod(shardDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) + + // hb (blocked) is processed first: if store.Remove's failure aborted the loop + // instead of warning and continuing, hg would never be reached. + awRepo.OrphanHashes = []string{hb, hg} + + // Prune still errors: Sweep independently revisits hb's leftover file and, + // unlike the loop below, has no warn-and-continue fallback of its own. + err := Prune(context.Background(), ds, store) + Expect(err).To(HaveOccurred()) + + // hg: reached and fully pruned despite being queued after the failing hb - + // proof the loop didn't return/break on the first Remove error. + _, err = awRepo.GetImage(hg) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(hg, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + + // hb: row still purged (DeleteOrphans doesn't depend on file removal), but the + // file itself survives since store.Remove failed and only warned. + _, 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() + }) +}) diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go deleted file mode 100644 index fe4a1a545..000000000 --- a/core/artwork/reader_album_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "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("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)) - }) - }) -}) diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go deleted file mode 100644 index 6d6d58fc5..000000000 --- a/core/artwork/reader_artist_test.go +++ /dev/null @@ -1,748 +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("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 - } -} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go deleted file mode 100644 index eac3c5e70..000000000 --- a/core/artwork/reader_mediafile.go +++ /dev/null @@ -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...) -} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go deleted file mode 100644 index 09707843d..000000000 --- a/core/artwork/reader_playlist.go +++ /dev/null @@ -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 -} diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go deleted file mode 100644 index 22db6e302..000000000 --- a/core/artwork/reader_radio.go +++ /dev/null @@ -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()) -} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go deleted file mode 100644 index 37ce1d827..000000000 --- a/core/artwork/reader_radio_test.go +++ /dev/null @@ -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()) - }) - }) - }) -}) diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go deleted file mode 100644 index 7c14f5e44..000000000 --- a/core/artwork/reader_resized_test.go +++ /dev/null @@ -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 -} diff --git a/core/artwork/reader_resized.go b/core/artwork/resize.go similarity index 60% rename from core/artwork/reader_resized.go rename to core/artwork/resize.go index cd16cbada..e213cf5d0 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/resize.go @@ -10,12 +10,11 @@ import ( "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" - "github.com/navidrome/navidrome/model" xdraw "golang.org/x/image/draw" ) @@ -41,84 +40,14 @@ var bufPool = sync.Pool{ }, } -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) - } - +// 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) { // Preserve animation for animated images if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { + if ffm.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) + r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) if err == nil { return r, 0, nil } @@ -129,7 +58,7 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return bytes.NewReader(data), 0, nil } - return resizeStaticImage(data, a.size, a.square) + return resizeStaticImage(data, size, square) } // toFastScaleType converts images whose concrete type has no optimized scaler @@ -207,3 +136,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro 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) +} diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go new file mode 100644 index 000000000..ff095bf24 --- /dev/null +++ b/core/artwork/resolve.go @@ -0,0 +1,462 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/draw" + "image/png" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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: forces failed (never absent). + // On a hit: a higher-priority external step failed—serve this, but retry later. + extError bool +} + +// resolveItem walks the kind's priority chain and returns the first hit. +func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc) (resolution, error) { + return resolveItemMode(ctx, ds, ag, ffmpeg, item, gate, false) +} + +// resolveItemLocal resolves using only local sources for the serving path's provisional +// read-through: external steps are skipped and the worker-built playlist grid is not assembled. +func resolveItemLocal(ctx context.Context, ds model.DataStore, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem) (resolution, error) { + return resolveItemMode(ctx, ds, nil, ffmpeg, item, denyGate, true) +} + +func resolveItemMode(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc, localOnly bool) (resolution, error) { + if gate == nil { + gate = passthroughGate + } + switch item.ItemKind { + case "al": + return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) + case "ar": + return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) + case "pl": + return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) + case "ra": + return resolveRadio(ctx, ds, item.ItemID) + case "mf": + return resolveMediaFile(ctx, ds, ffmpeg, item.ItemID) + default: + return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind) + } +} + +// resolveAlbum ports the folder/embedded/external selection from +// reader_album.go, walking conf.Server.CoverArtPriority. +func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc, localOnly bool) (resolution, error) { + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return resolution{}, err + } + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al) + if err != nil { + return resolution{}, err + } + lib, err := loadLibraryView(ctx, ds, al.LibraryID) + if err != nil { + return resolution{}, err + } + + var extErr bool + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok { + res.extError = extErr + return res, nil + } + case pattern == "external": + if localOnly { + continue + } + if r, name, isErr := fetchAlbumImage(ctx, ag, gate, *al); r != nil { + return resolution{reader: r, source: "external:" + name}, nil + } else if isErr { + extErr = true + } + case len(imgFiles) > 0: + if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok { + res.extError = extErr + return res, nil + } + } + } + return resolution{extError: extErr}, nil +} + +// resolveArtist ports the upload/folder/external selection from +// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority. +func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc, localOnly bool) (resolution, error) { + ar, err := ds.Artist(ctx).Get(artistID) + if err != nil { + return resolution{}, err + } + if res, ok := resolveLocalFile(ar.UploadedImagePath(), "upload"); ok { + return res, nil + } + + // Only consider albums where the artist is the sole album artist, same as reader_artist.go. + als, err := 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 := loadAlbumFoldersPaths(ctx, ds, als...) + if err != nil { + return resolution{}, err + } + artistFolder, _, err := loadArtistFolder(ctx, ds, als, albumPaths) + if err != nil { + return resolution{}, err + } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, ds, als[0].LibraryID) + if err != nil { + return resolution{}, err + } + } + + var extErr bool + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + if localOnly { + continue + } + if r, name, isErr := fetchArtistImage(ctx, ag, gate, *ar); r != nil { + return resolution{reader: r, source: "external:" + name}, nil + } else if isErr { + extErr = true + } + case pattern == "image-folder": + if res, ok := resolveArtistImageFolder(ar); ok { + res.extError = extErr + return res, nil + } + case strings.HasPrefix(pattern, "album/"): + if lib.FS == nil { + continue + } + if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok { + res.extError = extErr + return res, nil + } + default: + if lib.FS == nil || artistFolder == "" { + continue + } + if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok { + res.extError = extErr + return res, nil + } + } + } + return resolution{extError: extErr}, nil +} + +// resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar, +// ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum. +func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc, localOnly bool) (resolution, error) { + pl, err := ds.Playlist(ctx).Get(playlistID) + if err != nil { + return resolution{}, err + } + + var extErr bool + if res, ok := resolveLocalFile(pl.UploadedImagePath(), "upload"); ok { + return res, nil + } + if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok { + return res, nil + } + // A local ExternalImageURL is a file-backed reference: serve it in place (staleness-checked, + // and available even on the request path). Only http(s) URLs need the gated remote fetch. + localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) + if localImg != "" { + if res, ok := resolveLocalFile(localImg, "folder"); ok { + return res, nil + } + } + if localOnly { + // The remote ExternalImageURL fetch and the 2x2 grid are worker-only; a request must + // not fetch remotely nor sample album art synchronously. + return resolution{}, nil + } + if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { + sf := func() (io.ReadCloser, string, error) { return fetchPlaylistImageURL(ctx, remoteImg) } + if res, ok, isErr := resolveExternalStep(gate, "m3u", sf); ok { + return res, nil + } else if isErr { + extErr = true + } + } + + albumIDs, err := 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 := resolveAlbum(ctx, ds, ag, ffm, albumID, gate, false) + 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: propagate + // internal errors, and force extError for external ones. + 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, mirroring reader_playlist.go's loadTiles. + switch len(tiles) { + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + r, err := assembleTiles(tiles) + if err != nil { + return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error + } + return resolution{reader: r, source: "generated", extError: extErr}, nil +} + +// resolveRadio ports reader_radio.go: only an uploaded image, no fallback. +func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (resolution, error) { + r, err := ds.Radio(ctx).Get(radioID) + if err != nil { + return resolution{}, err + } + res, _ := resolveLocalFile(r.UploadedImagePath(), "upload") + return res, nil +} + +// resolveMediaFile resolves a track's own embedded art only; there is no folder or +// external fallback, so disabled/missing cover art is a definitive absent. +func resolveMediaFile(ctx context.Context, ds model.DataStore, ffm ffmpeg.FFmpeg, id string) (resolution, error) { + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return resolution{}, err + } + if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + return resolution{}, nil + } + lib, err := loadLibraryView(ctx, ds, mf.LibraryID) + if err != nil { + return resolution{}, err + } + res, _ := resolveEmbedded(ctx, lib, ffm, mf.Path) + return res, nil +} + +// resolveExternalStep runs a single external sourceFunc through the named gate; used by +// the playlist ExternalImageURL step. ok reports a hit; extErr reports a non-not-found +// error (a not-found is a definitive "no", not 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 +// (served file-backed) or a remote http(s) URL (fetched and stored); 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 + } +} + +// Like sources.go's fromURL but maps 404/410 to ErrNotFound (definitive), so a stale M3U +// cover URL falls through to the grid instead of retrying forever and tripping the breaker. +func fetchPlaylistImageURL(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) + req.Header.Set("User-Agent", consts.HTTPUserAgent) + resp, err := hc.Do(req) //nolint:gosec + if err != nil { + return nil, "", err + } + 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) + } + return resp.Body, imageURL.String(), nil +} + +func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { + if embedRel == "" { + return resolution{}, false + } + abs := lib.Abs(embedRel) + for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { + if r, _, _ := sf(); r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true + } + } + return resolution{}, false +} + +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + r, path, _ := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() + if r == nil { + return resolution{}, 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, _ := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() + if r == nil { + return resolution{}, false + } + return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true +} + +// resolveLocalFile opens an absolute path directly (uploads, image-folder). A +// missing or unreadable path is "no source", not an error. +func resolveLocalFile(path, source string) (resolution, bool) { + if path == "" { + return resolution{}, false + } + f, err := os.Open(path) + if err != nil { + return resolution{}, 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 instead of a joined absolute path, +// since library roots in tests may not be real OS paths (e.g. testfile://). +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() +} + +// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/ +// createTiledImage, reusing the same rect/fillCenter cropping helpers. +// decodeTile runs on every sampled album's resolved bytes before processItem's +// own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too. +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 +} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go new file mode 100644 index 000000000..63637240f --- /dev/null +++ b/core/artwork/resolve_test.go @@ -0,0 +1,594 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "image" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + + "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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + }) + + It("returns the error when the track is not in the DB", func() { + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, gate) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, gate) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil) + 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], matching + // reader_playlist.go's createTiledImage exactly. + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, gate) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + Expect(res.extError).To(BeFalse()) + }) + + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil) + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil) + 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() { + // End-to-end regression: a bomb-declaring tile must not break the grid. + 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 := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}, nil) + 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() { + // "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum + // returns a genuine (non-external) error for every sampled tile. + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + }) +}) + +// decodeTile runs on every sampled album's resolved bytes before processItem's +// own guards apply, so it must enforce the same caps independently. +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()) + }) +}) diff --git a/core/artwork/serving.go b/core/artwork/serving.go new file mode 100644 index 000000000..f4e67cd19 --- /dev/null +++ b/core/artwork/serving.go @@ -0,0 +1,417 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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" +) + +var ErrUnavailable = errors.New("artwork unavailable") + +// errStaleSource signals that a backing file's mtime no longer matches the state +// row's RefMtime: the stored hash may be stale, so the load is aborted (dangling). +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 hash (immutable URL match); "" for placeholders + ETag string // served-representation validator; "" falls back to Hash (full-size original) + LastUpdated time.Time // zero for placeholders + Placeholder bool +} + +// representationTag identifies a served resized representation for HTTP validation: it changes with +// the dimensions and the encode settings (CoverArtQuality/EnableWebPEncoding), 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 Service interface { + // Get serves resolved/provisional artwork; ErrUnavailable or model.ErrNotFound when + // there is nothing to serve (absent, pending, dangling) — caller picks placeholder vs 404. + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) + // GetOrPlaceholder parses a raw id token (raw entity ids accepted, as today) and falls + // back to the kind's placeholder image (never resized, Placeholder=true). + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) +} + +func NewService(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Service { + return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} +} + +type service struct { + ds model.DataStore + cache cache.FileCache + store *ImageStore + ffmpeg ffmpeg.FFmpeg +} + +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 { + img, err = s.Get(ctx, artID, size, square) + } + if errors.Is(err, ErrUnavailable) || errors.Is(err, model.ErrNotFound) { + return s.placeholder(artID.Kind), nil + } + return img, err +} + +func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + if artID.ID == "" { + return nil, ErrUnavailable + } + if size < 0 { + size = 0 // a negative size is a full-size request, 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) + } +} + +// serveEntity serves an entity whose state the worker owns (album/artist/playlist/radio): +// found row serves its hash, absent row is unavailable, missing row reads through provisionally. +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.Prefix(), 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 == "": + return nil, ErrUnavailable + default: + return s.serveHash(ctx, artID, ia, size, square) + } +} + +// serveHash serves the bytes of a found state row: full-size streams the original, sized +// goes through the resize cache. A mismatch/open error is dangling (a warm cache still serves). +func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { + 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 + } + + if size == 0 && !square { + rc, err := openOriginal(ia, art.Mime, s.store) + if err != nil { + return s.dangling(ctx, artID) + } + return &Image{ReadCloser: rc, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil + } + + item := newResizedItem(ia, art.Mime, size, square, s.store, s.ffmpeg) + stream, err := s.cache.Get(ctx, item) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + return s.dangling(ctx, artID) + } + return &Image{ReadCloser: stream, Hash: ia.Hash, ETag: representationTag(ia.Hash, size, square), LastUpdated: ia.UpdatedAt}, nil +} + +// openOriginal opens the full-resolution bytes for a found state row, enforcing 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() + return nil, errStaleSource + } + return f, nil + } + // Store-backed (embedded/external/generated): the bytes live in the content-addressed + // store, but an embedded source still carries the audio file's mtime to detect edits. + if ia.SourcePath != "" && ia.RefMtime != 0 { + info, err := os.Stat(ia.SourcePath) + if err != nil { + return nil, err + } + if info.ModTime().UnixNano() != ia.RefMtime { + return nil, errStaleSource + } + } + return store.Open(ia.Hash, mime) +} + +// newResizedItem builds the resize-cache reader for a found state row's bytes; shared by +// the serving path and the worker's precache so both key the cache identically. +func newResizedItem(ia *model.ItemArtwork, mime string, size int, square bool, store *ImageStore, ffm ffmpeg.FFmpeg) *resizedItem { + return &resizedItem{ + hash: ia.Hash, + size: size, + square: square, + lastUpdate: ia.UpdatedAt, + ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return openOriginal(ia, mime, store) }, + } +} + +// provisional does a local-only read-through for an entity with no state row: it enqueues +// the worker (Bump) and serves any local bytes immediately, never writing a state row. +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 := resolveItemLocal(ctx, s.ds, s.ffmpeg, item) + if err != nil { + return nil, err + } + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + return s.serveResolution(ctx, res, size, square) +} + +// serveResolution turns a local resolution's bytes into a servable Image (byte-hash +// only, no decode). A resolution with no reader is unavailable. +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 + } + return s.serveBytes(ctx, hash, data, unixMtime(res.refMtime), size, square) +} + +// serveBytes serves in-memory bytes: full-size directly, sized through the resize +// cache keyed by the byte-hash (so it lines up with the worker's eventual store entry). +func (s *service) serveBytes(ctx context.Context, hash string, data []byte, lastUpdate time.Time, size int, square bool) (*Image, error) { + if size == 0 && !square { + return &Image{ReadCloser: io.NopCloser(bytes.NewReader(data)), Hash: hash, LastUpdated: lastUpdate}, nil + } + item := &resizedItem{ + hash: hash, + size: size, + square: square, + lastUpdate: lastUpdate, + ffmpeg: s.ffmpeg, + open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }, + } + stream, err := s.cache.Get(ctx, item) + if err != nil { + return nil, err + } + return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(hash, size, square), LastUpdated: lastUpdate}, nil +} + +// serveMediaFile serves a track: own found art wins; an absent row delegates to the album; +// a missing row extracts embedded art (if eligible, enqueuing) else delegates without enqueue. +func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + // Per-track art can be disabled after mf rows were resolved (the setting is not in the + // config fingerprint). Honor it at serve time so a direct mf- URL falls 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("mf", 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 to album delegation + case errors.Is(err, model.ErrNotFound): + // no row → fall through to embedded eligibility / album delegation + 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's fallback: a multi-disc track defers to its disc art + // (which itself falls back to the album), not straight to the album. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) +} + +// provisionalEmbedded extracts a track's embedded art for an immediate serve and always +// enqueues the track (Bump) so the worker persists state; it never writes a state row. +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, _ := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + return s.serveResolution(ctx, res, size, square) +} + +// serveDisc serves disc-level artwork as a pure provisional read-through: no state rows, +// no enqueue. It tries the disc-folder selection chain and falls 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 + } + // Only multi-disc albums use disc-specific resolution (matching the legacy reader); a + // single-disc album serves album art directly, so a stray disc*/embedded image can't + // shadow higher-priority album art. + if len(dr.album.Discs) > 1 { + funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) + if r, path, err := selectImageReader(ctx, artID, funcs...); err == nil && r != nil { + defer r.Close() + if data, rerr := readCapped(r); rerr == nil { + if hash, herr := HashImage(bytes.NewReader(data)); herr == nil { + return s.serveBytes(ctx, hash, data, unixMtime(mtimeViaFS(dr.lib.FS, path)), size, square) + } + } + } + } + albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} + return s.Get(ctx, albumArtID, size, square) +} + +// dangling enqueues a re-resolution at Scan priority and reports the artwork as +// unavailable, leaving the state row untouched. +func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { + s.enqueue(ctx, artID, model.ArtworkPriorityScan) + return nil, ErrUnavailable +} + +// enqueue schedules a request-triggered re-resolution. It uses EnqueueBump so an incidental +// read-through never resets a failed resolution's backoff (unlike scan/manual re-resolve). +func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { + err := s.ds.ArtworkQueue(ctx).EnqueueBump(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 (s *service) placeholder(kind model.Kind) *Image { + return placeholderImage(kind) +} + +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} +} + +// PlaceholderFor returns the kind-appropriate placeholder for an artwork id, for callers that must +// serve a placeholder without consulting persisted state (e.g. an access-control denial). +func PlaceholderFor(id string) *Image { + artID, _ := model.ParseArtworkID(id) + return placeholderImage(artID.Kind) +} + +type coverArtIDGetter interface { + CoverArtID() model.ArtworkID +} + +// parseArtworkID ports the legacy getArtworkId: parse the token, and if it is a raw +// entity id, resolve the entity and take its CoverArtID. +func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { + if id == "" { + return model.ArtworkID{}, ErrUnavailable + } + if artID, err := model.ParseArtworkID(id); err == nil { + return artID, nil + } + entity, err := model.GetEntityByID(ctx, s.ds, id) + if err != nil { + return model.ArtworkID{}, err + } + if e, ok := entity.(coverArtIDGetter); ok { + return e.CoverArtID(), nil + } + return model.ArtworkID{}, model.ErrNotFound +} + +func unixMtime(mtime int64) time.Time { + if mtime <= 0 { + return time.Time{} + } + return time.Unix(0, mtime) // RefMtime is unix-nanoseconds +} + +// 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 + lastUpdate time.Time + ffmpeg ffmpeg.FFmpeg + open func() (io.ReadCloser, error) +} + +func (r *resizedItem) Key() string { + return fmt.Sprintf("h-%s.%d.%v.%s", r.hash, r.size, r.square, formatQualityTag()) +} + +func (r *resizedItem) LastUpdated() time.Time { return r.lastUpdate } + +func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, string, 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)), r.Key(), nil + } + if rc, ok := resized.(io.ReadCloser); ok { + return rc, r.Key(), nil + } + return io.NopCloser(resized), r.Key(), nil +} diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go new file mode 100644 index 000000000..0f344e4a0 --- /dev/null +++ b/core/artwork/serving_test.go @@ -0,0 +1,372 @@ +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/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("Service", func() { + 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 Service + repoRoot string + coverBytes []byte + ) + + primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } + + // seedFoundStore installs a store-backed found state (bytes in the content-addressed + // store, no backing file) and returns the hash. + 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()) + return hash + } + + 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()) + 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) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + svc = NewService(ds, imgCache, store, ffm) + }) + + 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) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + Expect(err).ToNot(HaveOccurred()) + // A resized response versions its validator with the encode settings, distinct from + // the pixel hash, so a CoverArtQuality/WebP change invalidates client caches. + 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)) + + // Delete the store file: a warm resize-cache entry must keep serving without + // ever touching the original (the stale-serve self-heal). + hash, _ := HashImage(bytes.NewReader(coverBytes)) + Expect(store.Remove(hash, "image/jpeg", time.Now().Add(time.Hour))).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()) + }) + + 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()) + 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()) + 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("al", "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()) + 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)) + }) + + It("returns ErrUnavailable for an absent state without enqueuing", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data).To(BeEmpty()) + }) + }) + + 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("al", "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("al", "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 + // A resolved mf row exists (from when the setting was on) but must not be served. + 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("mf", "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() { + // Not embedded-eligible: the fallback must mirror CoverArtID (disc first, then album). + 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 wins over the album's found art, proving it routed via serveDisc. + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("delegates a single-disc track straight to the album, skipping disc resolution", func() { + 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()) + // Single-disc album: album art wins; the folder disc image must not shadow it. + Expect(readAll(img)).To(Equal([]byte("album-art-distinct"))) + }) + }) + + 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)) + }) + + 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)) + }) + }) +}) + +func fileMtime(path string) int64 { + GinkgoHelper() + info, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + return info.ModTime().UnixNano() +} diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 04a9257fb..9e9916e3e 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -16,11 +16,9 @@ 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" ) @@ -171,44 +169,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) diff --git a/core/artwork/testhelpers_test.go b/core/artwork/testhelpers_test.go new file mode 100644 index 000000000..97f69136a --- /dev/null +++ b/core/artwork/testhelpers_test.go @@ -0,0 +1,37 @@ +package artwork + +import ( + "github.com/navidrome/navidrome/model" +) + +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 +} diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 63231b54a..8efb30da2 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -5,7 +5,8 @@ import ( ) var Set = wire.NewSet( - NewArtwork, + NewService, GetImageCache, - NewCacheWarmer, + NewWorker, + ProvideImageStore, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go new file mode 100644 index 000000000..5191e2285 --- /dev/null +++ b/core/artwork/worker.go @@ -0,0 +1,361 @@ +package artwork + +import ( + "context" + "errors" + "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" + "golang.org/x/time/rate" +) + +const ( + workerPollInterval = 5 * time.Second + backoffBase = 5 * time.Minute + backoffCap = 48 * time.Hour + breakerThreshold = 5 + breakerProbeAfter = time.Minute +) + +var errBreakerOpen = errors.New("artwork: external circuit breaker open") + +// extGate is one agent's rate limiter + circuit breaker; each external agent gets its +// own so a provider whose API or CDN is down backs off in isolation from the others. +type extGate struct { + limiter *rate.Limiter + breaker *breaker +} + +// Worker drains the artwork queue through processItem: each external agent is rate-limited +// and circuit-broken independently, and prune is serialized against in-flight acquisitions +// via pruneMu. +type Worker struct { + deps workerDeps + broker events.Broker + pruneMu sync.RWMutex + wake chan struct{} + runCtx context.Context + + gatesMu sync.Mutex + gates map[string]*extGate + + mu sync.Mutex + inFlight map[string]struct{} +} + +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { + w := &Worker{ + deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg, cache: imgCache}, + broker: broker, + wake: make(chan struct{}, 1), + runCtx: context.Background(), + gates: map[string]*extGate{}, + inFlight: map[string]struct{}{}, + } + w.deps.gate = w.gate + return w +} + +// Run blocks draining the queue until ctx is cancelled. It exits cleanly with no +// leaked goroutines: each drain waits for its batch before the loop can return. +func (w *Worker) Run(ctx context.Context) error { + w.runCtx = ctx + concurrency := max(1, conf.Server.ArtworkWorkerConcurrency) + ticker := time.NewTicker(workerPollInterval) + defer ticker.Stop() + for { + n, err := w.drain(ctx, concurrency) + if err != nil && ctx.Err() == nil { + log.Warn(ctx, "artwork: worker drain failed", err) + } + if ctx.Err() != nil { + return nil + } + if n > 0 { + continue // keep draining while the queue has ready work + } + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + case <-w.wake: + } + } +} + +// Bump enqueues an item at the highest priority and wakes the drain loop. It is +// non-blocking: a wake already pending is enough. +func (w *Worker) Bump(kind, id string) { + item := model.ArtworkQueueItem{ + ItemKind: kind, + ItemID: id, + ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + } + if err := w.deps.ds.ArtworkQueue(context.Background()).Enqueue(item); err != nil { + log.Warn("artwork: could not bump queue item", "kind", kind, "id", id, err) + return + } + select { + case w.wake <- struct{}{}: + default: + } +} + +// 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.deps.ds, w.deps.store) +} + +func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { + // Resolved per drain, not once in Run: the worker starts at boot, possibly before any + // admin exists, so a late-created admin is picked up on the next poll (private playlists). + ctx = auth.WithAdminUser(ctx, w.deps.ds) + batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency) + if err != nil { + return 0, err + } + items := w.claim(batch) + if len(items) == 0 { + return 0, nil + } + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var refreshMu sync.Mutex + var refresh []model.ArtworkQueueItem + for _, item := range items { + sem <- struct{}{} + wg.Add(1) + go func(it model.ArtworkQueueItem) { + defer wg.Done() + defer func() { <-sem }() + defer w.release(it) + out := w.process(ctx, it) + // Refresh clients on any visible state change: found/foundStale (new art) and absent + // (removed art — clients must drop a previously-served immutable cover). foundStale + // also wrote a served state row. + if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { + refreshMu.Lock() + refresh = append(refresh, it) + refreshMu.Unlock() + } + // Precache only actual images. Post-outcome only: the queue row was already settled + // by process, so warming the resize cache here can never block or alter queue ops. + if out == outcomeFound || out == outcomeFoundStale { + w.precache(ctx, it) + } + }(item) + } + wg.Wait() + w.broadcastRefresh(ctx, refresh) + return len(items), nil +} + +// artworkKindToResource maps a queue item's kind to the UI resource name carried +// in the refresh event. +var artworkKindToResource = map[string]string{ + "al": "album", + "ar": "artist", + "pl": "playlist", + "ra": "radio", + "mf": "song", +} + +// broadcastRefresh emits one coalesced RefreshResource for the batch's newly-acquired +// artwork, so connected 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 { + if res, ok := artworkKindToResource[it.ItemKind]; 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 { + if item.ImageType == "" { + item.ImageType = model.ImageTypePrimary + } + w.pruneMu.RLock() + out := processItem(ctx, &w.deps, item) + w.pruneMu.RUnlock() + + queue := w.deps.ds.ArtworkQueue(ctx) + switch out { + case outcomeFound, outcomeAbsent: + // DeleteIfUnchanged, not Delete: 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: + // MarkFailedIfUnchanged, not MarkFailed: a scan that re-enqueued this row mid-flight reset + // retry_at, so stale backoff must not stomp its fresh, immediate eligibility. + retryAt := time.Now().Add(backoff(item.Attempts)) + 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) + } + } + return out +} + +// precache warms the resize cache for a newly-acquired image at the UI cover size, so the +// first UI request is a cache hit. Skipped when disabled; failures are debug-only. +func (w *Worker) precache(ctx context.Context, item model.ArtworkQueueItem) { + if !conf.Server.EnableArtworkPrecache || w.deps.cache == nil || w.deps.cache.Disabled(ctx) { + return + } + imageType := item.ImageType + if imageType == "" { + imageType = model.ImageTypePrimary + } + repo := w.deps.ds.Artwork(ctx) + ia, err := repo.GetItemArtwork(item.ItemKind, item.ItemID, imageType) + if err != nil || ia.Hash == "" { + return + } + art, err := repo.GetImage(ia.Hash) + if err != nil { + return + } + stream, err := w.deps.cache.Get(ctx, newResizedItem(ia, art.Mime, conf.Server.UICoverArtSize, false, w.deps.store, w.deps.ffmpeg)) + if err != nil { + log.Debug(ctx, "artwork: precache failed", "kind", item.ItemKind, "id", item.ItemID, err) + return + } + _, _ = io.Copy(io.Discard, stream) + _ = stream.Close() +} + +// claim reserves items not already in flight, so a row appearing twice within a single +// batch is processed once. +func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem { + w.mu.Lock() + defer w.mu.Unlock() + var out []model.ArtworkQueueItem + for _, it := range batch { + k := queueKey(it) + if _, busy := w.inFlight[k]; busy { + continue + } + w.inFlight[k] = struct{}{} + out = append(out, it) + } + return out +} + +func (w *Worker) release(it model.ArtworkQueueItem) { + w.mu.Lock() + delete(w.inFlight, queueKey(it)) + w.mu.Unlock() +} + +func queueKey(it model.ArtworkQueueItem) string { + return it.ItemKind + "|" + it.ItemID + "|" + it.ImageType +} + +// gate wraps a named external step with that agent's own rate limiter and circuit +// breaker, matching gateFunc so it can be injected via workerDeps.gate. +func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + g := w.gateFor(name) + if !g.breaker.allow() { + return nil, "", errBreakerOpen + } + if err := g.limiter.Wait(w.runCtx); err != nil { + return nil, "", err + } + r, path, err := f() + g.breaker.record(err) + return r, path, err +} + +// gateFor lazily creates the per-name gate on first use, each with its own limiter at +// ArtworkExternalMaxRPS and its own breaker. +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.ArtworkExternalMaxRPS + 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 +} + +// backoffFor returns min(5m×4^n, 48h) scaled by (1+jitter), with jitter in [-0.2, 0.2]. +func backoffFor(attempts int, jitter float64) time.Duration { + d := math.Min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(backoffCap)) + return time.Duration(d * (1 + jitter)) +} + +func backoff(attempts int) time.Duration { + return backoffFor(attempts, rand.Float64()*0.4-0.2) //nolint:gosec // retry jitter, not security-sensitive +} + +// breaker opens after breakerThreshold consecutive external 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(err error) { + b.mu.Lock() + defer b.mu.Unlock() + // A not-found (from either package) is a definitive answer, not a fault; only real + // errors trip the breaker. Must stay consistent with isTransientExternal. + if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) { + b.failures = 0 + return + } + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + } +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go new file mode 100644 index 000000000..59a042106 --- /dev/null +++ b/core/artwork/worker_soak_test.go @@ -0,0 +1,151 @@ +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" +) + +// soakCycles is deliberately >2000: this is a leak regression guard, not a +// performance benchmark, so it favors a stable signal over raw speed. +const soakCycles = 2200 + +var _ = Describe("Worker soak", func() { + // Runs processItem over many cycles across a mix of sources, asserting + // goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short. + 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()) + deps := &workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffm} + conf.Server.CoverArtPriority = "cover.jpg, embedded" + + // Dangling refs (al/ra ids the repos don't know about) mirror an entity + // deleted after being enqueued; ds.Radio auto-provisions an empty mock repo. + 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 (GC workers, etc.) can take a moment to wind down; + // poll for two consecutive equal samples instead of trusting a single one. + 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 := processItem(context.Background(), deps, it) + + // "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would + // use after acquisition, not the old serving pipeline. + if out == outcomeFound { + ia, err := artRepo.GetItemArtwork(it.ItemKind, 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) + } + }) +}) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go new file mode 100644 index 000000000..d773d6bec --- /dev/null +++ b/core/artwork/worker_test.go @@ -0,0 +1,595 @@ +package artwork + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "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/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/goleak" +) + +// recordingCache captures the keys passed to Get so precache warming can be asserted, +// and can be forced Disabled to exercise the skip path. +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 append([]string(nil), c.keys...) +} + +// reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the +// worker's delete by bumping retry_at, so a DeleteIfUnchanged on the dequeued value no-ops. +type reenqueueOnDequeue struct { + *tests.MockArtworkQueueRepo + done bool +} + +func (r *reenqueueOnDequeue) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + items, err := r.MockArtworkQueueRepo.DequeueBatch(n) + 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 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.ArtworkExternalMaxRPS = 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) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + })} + Eventually(func() bool { return imgCache.Available(ctx) }).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("al", "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("mf", "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("al", "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("al", "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("al", "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 concurrent re-enqueue reset retry_at; the failure path must not stomp it + // with stale backoff nor bump attempts, so the row stays immediately eligible. + it := findQueued(queueRepo, "al", "al8") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(BeZero()) + Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) + }) + + 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)) + + // Resolved as absent (no art) and removed — not stuck failing on ErrNotFound forever. + Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) + ia, err := artRepo.GetItemArtwork("pl", "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")) + }) + + 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("al", "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("Bump", func() { + It("enqueues at Bump priority and wakes the loop", func() { + w.Bump("al", "al9") + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.Priority).To(Equal(model.ArtworkPriorityBump)) + }) + }) + + 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() { + // Regression: agents.ErrNotFound is a definitive miss, not a fault. A run of + // artless items must never trip the breaker, or they'd loop in retry instead of + // settling absent. Uses the real gate, not passthroughGate. + 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)) + + Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.false."))) + }) + + 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()) + }) + }) + + 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.Minute}, + {1, 20 * time.Minute}, + {2, 80 * time.Minute}, + {3, 320 * time.Minute}, + {4, 1280 * time.Minute}, + {5, 48 * time.Hour}, + {6, 48 * 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 +/-20%", func() { + lo := time.Duration(float64(320*time.Minute) * 0.8) + hi := time.Duration(float64(320*time.Minute) * 1.2) + for range 200 { + d := backoff(3) + Expect(d).To(BeNumerically(">=", lo)) + Expect(d).To(BeNumerically("<=", hi)) + } + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go new file mode 100644 index 000000000..dbaed0a84 --- /dev/null +++ b/core/artwork/worker_timing_test.go @@ -0,0 +1,78 @@ +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(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(errors.New("boom")) // probe fails -> stay open + time.Sleep(breakerProbeAfter) + g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + + b.record(nil) // probe succeeds -> close + g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") + g.Expect(b.allow()).To(BeTrue()) + }) +} + +// Drives the worker's per-name gate map with the fake clock: one agent's open breaker +// must neither block another agent nor short-circuit the other's probe recovery. +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)) + }) +} diff --git a/core/external/provider.go b/core/external/provider.go index 459e8a205..0a61947a5 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -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 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 string, count int) (model.MediaFiles, error) { artist, err := e.findArtistByName(ctx, artistName) if err != nil { @@ -519,10 +446,15 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) @@ -535,6 +467,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, diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go deleted file mode 100644 index e801b7cce..000000000 --- a/core/external/provider_albumimage_test.go +++ /dev/null @@ -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) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go deleted file mode 100644 index 79612d651..000000000 --- a/core/external/provider_artistimage_test.go +++ /dev/null @@ -1,426 +0,0 @@ -package external_test - -import ( - "bytes" - "context" - "errors" - "net/url" - "time" - - "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/log" - "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 - ArtistImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockImageAgent *mockArtistImageAgent - var agentsCombined *mockAgents - var ctx context.Context - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockImage" // Configure only the mock agent - ctx = GinkgoT().Context() - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockImageAgent = newMockArtistImageAgent() - - // Use the mockAgents from helper, setting the specific agent - agentsCombined = &mockAgents{ - imageAgent: mockImageAgent, - } - - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks for successful Get calls - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() - // Default mock for non-existent entities - 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() - - // Default successful image agent response - mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist 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).Maybe() - }) - - AfterEach(func() { - mockArtistRepo.AssertExpectations(GinkgoT()) - mockAlbumRepo.AssertExpectations(GinkgoT()) - mockMediaFileRepo.AssertExpectations(GinkgoT()) - mockImageAgent.AssertExpectations(GinkgoT()) - }) - - It("returns the largest image URL when successful", func() { - // Arrange - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the artist is not found in the DB", func() { - // Arrange - - // Act - imgURL, err := provider.ArtistImage(ctx, "not-found") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - agentErr := errors.New("agent failure") - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns context error if context is canceled before agent call", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(context.Background()) - mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { - cancelCtx() // Cancel context *during* the DB call simulation - }).Once() - - // Act - imgURL, err := provider.ArtistImage(cctx, "artist-1") - - // Assert - Expect(err).To(MatchError(context.Canceled)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - }) - - It("derives artist ID from MediaFile ID", func() { - // Arrange: Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("derives artist ID from Album ID", func() { - // Arrange: Add mock for the initial GetEntityByID lookup - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "album-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if derived artist is not found", func() { - // Arrange - // Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() - // Add expectation for the recursive GetEntityByID call for the MediaFileRepo - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("handles different image orders from agent", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist 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") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/medium.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns cached URL and does not call agent when info is not expired", func() { - // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - cachedArtist := &model.Artist{ - ID: "artist-cached", - Name: "Cached Artist", - LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), - } - mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-cached") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) - - // Assert: background refresh was NOT enqueued - Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) - - }) - - It("returns stale URL and enqueues refresh when info is expired", func() { - // Arrange - conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - staleArtist := &model.Artist{ - ID: "artist-expired", - Name: "Expired Artist", - LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), - } - mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-expired") - - // Assert: returns stale URL immediately, no agent call - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) - - // Assert: background refresh was enqueued - Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) - }) - - Context("Unicode handling in artist names", func() { - var artistWithEnDash *model.Artist - var expectedURL *url.URL - - const ( - originalArtistName = "Run–D.M.C." // Artist name with en dash - normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in artist name like "Run–D.M.C." - artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") - - // Mock the image agent to return an image for the artist - mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/rundmc.jpg", Size: 1000}, - }, nil).Once() - - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - It("preserves Unicode characters in artist names", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This is the key assertion: ensure the original Unicode name is used - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") - }) - }) - }) -}) - -// mockArtistImageAgent implementation using testify/mock -// This remains local as it's specific to testing the ArtistImage functionality -type mockArtistImageAgent struct { - mock.Mock - agents.ArtistImageRetriever // Embed interface -} - -// Constructor for the mock agent -func newMockArtistImageAgent() *mockArtistImageAgent { - mock := new(mockArtistImageAgent) - // Set default AgentName if needed, although usually called via mockAgents - mock.On("AgentName").Return("mockImage").Maybe() - return mock -} - -func (m *mockArtistImageAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, id, artistName, mbid) - // Need careful type assertion for potentially nil slice - var res []agents.ExternalImage - if args.Get(0) != nil { - res = args.Get(0).([]agents.ExternalImage) - } - return res, args.Error(1) -} - -// Ensure mockAgent implements the interface -var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/core/image_upload.go b/core/image_upload.go index eb61b225a..ce32f8258 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -18,6 +18,9 @@ import ( 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 + // EnqueueArtwork clears an item's resolved state and re-queues it at Bump priority. Callers + // must invoke it AFTER persisting the new filename, so the worker never resolves the old one. + EnqueueArtwork(ctx context.Context, entityType, entityID string) } // MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default @@ -30,10 +33,20 @@ func MaxImageUploadSize() int64 { return int64(size) } -type imageUploadService struct{} +// uploadEntityKind maps an upload's entity type to its artwork kind prefix, so a +// successful upload can clear and re-queue that item's artwork state. +var uploadEntityKind = map[string]string{ + consts.EntityArtist: model.KindArtistArtwork.Prefix(), + consts.EntityPlaylist: model.KindPlaylistArtwork.Prefix(), + consts.EntityRadio: model.KindRadioArtwork.Prefix(), +} -func NewImageUploadService() ImageUploadService { - return &imageUploadService{} +type imageUploadService struct { + ds model.DataStore +} + +func NewImageUploadService(ds model.DataStore) ImageUploadService { + return &imageUploadService{ds: ds} } func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { @@ -61,10 +74,26 @@ 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 } +// EnqueueArtwork clears the item's resolved state and re-queues it at Bump priority: the +// upload is now the top-priority source, so the worker re-resolves and the UI swaps. +func (s *imageUploadService) EnqueueArtwork(ctx context.Context, entityType, id string) { + kind, ok := uploadEntityKind[entityType] + if !ok { + return + } + if err := s.ds.Artwork(ctx).DeleteForItem(kind, id); err != nil { + log.Warn(ctx, "Could not clear artwork state after upload", "kind", kind, "id", id, err) + } + item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := s.ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + log.Warn(ctx, "Could not enqueue artwork after upload", "kind", kind, "id", id, err) + } +} + func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { if path == "" { return nil diff --git a/core/image_upload_test.go b/core/image_upload_test.go index e7648df34..29ce69298 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -10,6 +10,8 @@ import ( "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" ) @@ -17,12 +19,17 @@ import ( var _ = Describe("ImageUploadService", func() { var svc core.ImageUploadService 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 = core.NewImageUploadService(ds) }) Describe("SetImage", func() { @@ -69,6 +76,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("ar", "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("ar", "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() { diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index f2866fb60..730af20cd 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -43,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -118,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -136,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -155,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -174,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -192,7 +192,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -226,7 +226,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -244,7 +244,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -258,7 +258,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -285,7 +285,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -311,7 +311,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -388,7 +388,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -448,7 +448,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("handles relative paths that reference files in other libraries", func() { @@ -604,7 +604,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -662,7 +662,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" plsFile := filepath.Join(tmpDir, "my-playlist.m3u") @@ -681,7 +681,7 @@ var _ = Describe("Playlists - Import", func() { libDir := filepath.Join(tmpDir, "music") Expect(os.Mkdir(libDir, 0755)).To(Succeed()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" plsFile := filepath.Join(tmpDir, "external.m3u") @@ -704,7 +704,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -724,7 +724,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -744,7 +744,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -767,7 +767,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 1ef083bbb..57e3e944d 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -57,6 +57,7 @@ type Playlists interface { 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 + EnqueueArtwork(ctx context.Context, entityType, entityID string) } type playlists struct { @@ -320,7 +321,11 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { @@ -334,5 +339,9 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 0c9674bed..0aeed5cd7 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -42,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to delete their playlist", func() { @@ -82,7 +82,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("returns the playlist's track repository", func() { @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("creates a new playlist with owner set from context", func() { @@ -161,7 +161,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to update their playlist", func() { @@ -219,7 +219,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to add tracks", func() { @@ -267,7 +267,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to remove tracks", func() { @@ -301,7 +301,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to reorder", func() { @@ -330,7 +330,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("saves image file and updates UploadedImage", func() { @@ -400,7 +400,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("removes file and clears UploadedImage", func() { @@ -420,6 +420,24 @@ var _ = Describe("Playlists", func() { Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) + It("clears the resolved artwork state and re-queues after removing an upload", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", + })).To(Succeed()) + + Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) + + _, err := ds.Artwork(ctx).GetItemArtwork("pl", "pls-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pls-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 58a327bde..0c18c416b 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -37,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) Describe("Save", func() { diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql new file mode 100644 index 000000000..bc400f90e --- /dev/null +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -0,0 +1,42 @@ +-- +goose Up +CREATE TABLE artwork ( + hash TEXT PRIMARY KEY, + mime TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + blur_hash TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE item_artwork ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + hash TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + ref_mtime INTEGER NOT NULL DEFAULT 0, + attempted_at TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); + +CREATE TABLE artwork_queue ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + priority INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +-- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. +CREATE INDEX ix_artwork_queue_drain ON artwork_queue(priority DESC, enqueued_at, retry_at); + +-- +goose Down +DROP TABLE artwork_queue; +DROP TABLE item_artwork; +DROP TABLE artwork; diff --git a/go.mod b/go.mod index 5488b41e4..b72f4035c 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 golang.org/x/image v0.44.0 @@ -128,7 +129,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/model/album.go b/model/album.go index 55df8c63c..05d907a28 100644 --- a/model/album.go +++ b/model/album.go @@ -13,6 +13,7 @@ import ( type Album struct { Annotations `structs:"-" hash:"ignore"` + ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` @@ -142,6 +143,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetAllIDs(...QueryOptions) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..6e0f073ef 100644 --- a/model/artist.go +++ b/model/artist.go @@ -11,6 +11,7 @@ import ( type Artist struct { Annotations `structs:"-"` + ItemImage `structs:"-" json:"-"` ID string `structs:"id" json:"id"` @@ -89,6 +90,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go new file mode 100644 index 000000000..a21936535 --- /dev/null +++ b/model/artwork.go @@ -0,0 +1,116 @@ +package model + +import "time" + +// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. +type Artwork struct { + Hash string `structs:"hash"` + Mime string `structs:"mime"` + Width int `structs:"width"` + Height int `structs:"height"` + SizeBytes int64 `structs:"size_bytes"` + BlurHash string `structs:"blur_hash"` + CreatedAt time.Time `structs:"created_at"` +} + +const ImageTypePrimary = "primary" + +// ItemImage is per-entity artwork state hydrated at query time; never persisted +// (structs:"-" keeps it out of upserts) nor exposed via the native API (json:"-"). +type ItemImage struct { + ImageHash string `structs:"-" json:"-"` + ImageAbsent bool `structs:"-" json:"-"` +} + +// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. +type ItemArtwork struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Hash string `structs:"hash"` + Source string `structs:"source"` + // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. + SourcePath string `structs:"source_path"` + // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. + RefMtime int64 `structs:"ref_mtime"` + // attempted_at/updated_at are nullable in the schema but always set by PutItemArtwork; + // raw inserts must set them too, since these non-pointer time.Time fields fail to scan NULL. + AttemptedAt time.Time `structs:"attempted_at"` + UpdatedAt time.Time `structs:"updated_at"` +} + +// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). +type ItemArtworkInfo struct { + ItemID string + Hash string + BlurHash string +} + +// Absent reports a known-absent artwork state (resolved, no image). +func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } + +type ArtworkQueueItem struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + RetryAt time.Time `structs:"retry_at"` + EnqueuedAt time.Time `structs:"enqueued_at"` +} + +// Queue priorities: higher drains first. +const ( + ArtworkPriorityRecheck = 0 + ArtworkPriorityBackfill = 10 + ArtworkPriorityScan = 50 + ArtworkPriorityBump = 100 +) + +type ArtworkRepository interface { + // Image identity (artwork table) + GetImage(hash string) (*Artwork, error) + PutImage(a *Artwork) error + GetImages(hashes []string) (map[string]Artwork, error) + // GetOrphanHashes returns hashes referenced by no item_artwork row and older than cutoff. + GetOrphanHashes(createdBefore time.Time) ([]string, error) + // DeleteOrphans deletes the given hashes only if still unreferenced and older than cutoff (atomic re-check). + DeleteOrphans(createdBefore time.Time, hashes []string) error + // Per-item state (item_artwork table) + GetItemArtwork(kind, id, imageType string) (*ItemArtwork, error) + PutItemArtwork(ia *ItemArtwork) error + DeleteForItem(kind, id string) error + // DeleteForItems removes state rows for the given ids of one kind, in chunks. + DeleteForItems(kind string, ids []string) error + // GetInfoForItems hydrates a page: one batched query, item_artwork joined to artwork. + GetInfoForItems(kind string, ids []string) (map[string]ItemArtworkInfo, error) + // GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks. + GetAllMimes() (map[string]string, error) + // PurgeDanglingItemArtwork removes state rows whose entity no longer exists. + PurgeDanglingItemArtwork() (int64, error) +} + +type ArtworkQueueRepository interface { + // Enqueue upserts; an existing row keeps the higher of the two priorities and has its + // retry_at reset (a detected change wants immediate re-resolution). + Enqueue(items ...ArtworkQueueItem) error + // EnqueueBump upserts like Enqueue but preserves an existing row's retry_at, so a + // request-triggered read-through never resets a failed resolution's backoff. + EnqueueBump(items ...ArtworkQueueItem) error + // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. + DequeueBatch(n int) ([]ArtworkQueueItem, error) + // MarkFailed increments attempts and pushes retry_at into the future. + MarkFailed(kind, id, imageType string, retryAt time.Time) error + // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches + // seenRetryAt; a concurrent re-enqueue (which resets retry_at) keeps its fresh eligibility. + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error + Delete(kind, id, imageType string) error + // DeleteIfUnchanged deletes the row only if its retry_at still matches retryAt, so a + // concurrent re-enqueue (which resets retry_at) survives instead of being erased. + DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error + Count() (int64, error) + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. + EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) + // PurgeDangling removes queue rows whose entity no longer exists. + PurgeDangling() (int64, error) +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..e308cb676 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -17,6 +17,11 @@ func (k Kind) String() string { return k.name } +// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. +func (k Kind) Prefix() string { + return k.prefix +} + var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} @@ -38,7 +43,8 @@ var artworkKindMap = map[string]Kind{ type ArtworkID struct { Kind Kind ID string - LastUpdate time.Time + Hash string // content-hash suffix; "" = unknown/none + LastUpdate time.Time // legacy: populated only when parsing old _ tokens } func (id ArtworkID) String() string { @@ -46,14 +52,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if lu := id.LastUpdate.Unix(); lu > 0 { - return fmt.Sprintf("%s_%x", s, lu) + if id.Hash != "" { + return s + "_" + id.Hash } - return s + "_0" + return s } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{kind, id, time.Time{}} + artID := ArtworkID{Kind: kind, ID: id} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -75,18 +81,34 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - if parts[1] != "0" { - lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) - if err != nil { - return ArtworkID{}, err - } - parsedID.LastUpdate = time.Unix(lastUpdate, 0) - } parsedID.ID = parts[0] + suffix := parts[1] + switch { + // Hash detection must come first: a 16-hex value with the high bit set overflows int64. + case isImageHash(suffix): + parsedID.Hash = suffix + case suffix != "0": + if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { + parsedID.LastUpdate = time.Unix(lastUpdate, 0) + } + } } return parsedID, nil } +// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. +func isImageHash(s string) bool { + if len(s) != 16 { + return false + } + for _, c := range s { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} + func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -112,40 +134,21 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{ - Kind: KindAlbumArtwork, - ID: al.ID, - LastUpdate: al.UpdatedAt, - } + return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{ - Kind: KindMediaFileArtwork, - ID: mf.ID, - LastUpdate: mf.UpdatedAt, - } + return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{ - Kind: KindPlaylistArtwork, - ID: pls.ID, - LastUpdate: pls.UpdatedAt, - } + return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{ - Kind: KindArtistArtwork, - ID: ar.ID, - } + return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} } func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{ - Kind: KindRadioArtwork, - ID: r.ID, - LastUpdate: r.UpdatedAt, - } + return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index ad66f7bb5..af6a12ffb 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,14 +9,31 @@ import ( ) var _ = Describe("ArtworkID", func() { + Describe("String()", func() { + It("returns a bare id when there is no hash", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} + Expect(id.String()).To(Equal("al-1234")) + }) + It("appends the hash suffix when set", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} + Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) + }) + It("never emits a legacy timestamp/_0 suffix", func() { + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + Expect(id.String()).To(Equal("al-1234")) + }) + It("returns empty string for an empty id", func() { + Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) + }) + }) + Describe("NewArtworkID()", func() { - It("creates a valid parseable ArtworkID", func() { + It("round-trips Kind and ID through String()", func() { id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -24,18 +41,16 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) + Describe("ParseArtworkID - disc kind", func() { It("parses a disc artwork ID with dc prefix", func() { - now := time.Now() - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) Expect(parsedId.ID).To(Equal("albumid123:2")) - Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) }) }) @@ -67,6 +82,7 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -74,12 +90,45 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlists artwork ids", func() { + It("parses playlist artwork ids with dashed UUID", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) + It("captures a 16-hex suffix as Hash", func() { + id, err := model.ParseArtworkID("al-1234_abcdef0123456789") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("abcdef0123456789")) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("captures a high-bit 16-hex suffix as Hash without error", func() { + id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("ffffffffffffffff")) + }) + It("parses a legacy hex-timestamp suffix as LastUpdate", func() { + id, err := model.ParseArtworkID("al-123_688a1b2c") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) + }) + It("parses a legacy _0 suffix", func() { + id, err := model.ParseArtworkID("al-123_0") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("silently drops a garbage suffix", func() { + id, err := model.ParseArtworkID("al-123_zz") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/datastore.go b/model/datastore.go index 94c3c3622..273ca714b 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -40,6 +40,8 @@ type DataStore interface { ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository + Artwork(ctx context.Context) ArtworkRepository + ArtworkQueue(ctx context.Context) ArtworkQueueRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/mediafile.go b/model/mediafile.go index 22ab7fbbe..7c56f43df 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -24,6 +24,7 @@ import ( type MediaFile struct { Annotations `structs:"-" hash:"ignore"` Bookmarkable `structs:"-" hash:"ignore"` + ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` @@ -139,13 +140,15 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + id := NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + id.Hash = mf.ImageHash + return id } return mf.AlbumCoverArtID() } func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.ItemImage}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { diff --git a/model/playlist.go b/model/playlist.go index 185f6f942..ac64cad62 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -13,6 +13,7 @@ import ( type Playlist struct { Annotations `structs:"-"` + ItemImage `structs:"-" json:"-"` ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` @@ -143,6 +144,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 86f27c24c..2a90d8c76 100644 --- a/model/radio.go +++ b/model/radio.go @@ -7,6 +7,8 @@ import ( ) type Radio struct { + ItemImage `structs:"-" json:"-"` + ID string `structs:"id" json:"id"` StreamUrl string `structs:"stream_url" json:"streamUrl"` Name string `structs:"name" json:"name"` @@ -32,5 +34,6 @@ type RadioRepository interface { Delete(id string) error Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) + GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go index 860331f17..6ae74c3f4 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -14,12 +14,11 @@ import ( var _ = Describe("Radio", func() { Describe("CoverArtID", func() { It("returns a radio artwork ID", func() { - now := time.Now() - r := model.Radio{ID: "rd-1", UpdatedAt: now} + r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} artID := r.CoverArtID() Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate).To(Equal(now)) + Expect(artID.LastUpdate.IsZero()).To(BeTrue()) }) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2bb541003..426048654 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -251,7 +251,30 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e if err != nil { return nil, err } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil +} + +// hydrateArtwork fills each album's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *albumRepository) hydrateArtwork(albums model.Albums) { + if len(albums) == 0 { + return + } + ids := slice.Map(albums, func(a model.Album) string { return a.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), ids) + for i := range albums { + applyItemImage(infos, albums[i].ID, &albums[i].ItemImage) + } +} + +// GetAllIDs returns just the album IDs for the same row set as GetAll, skipping the +// heavy column projection and JSON post-processing. Used by bulk enumeration (artwork backfill). +func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { @@ -405,7 +428,9 @@ func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 465617b10..ef18cf426 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -84,6 +84,21 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := albumRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, a := range want { + wantIDs = append(wantIDs, a.ID) + } + ids, err := albumRepo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..a78718de4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -250,6 +250,7 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) { return nil, model.ErrNotFound } res := dba.toModels() + r.hydrateArtwork(res) return &res[0], nil } @@ -261,9 +262,31 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return nil, err } res := dba.toModels() + r.hydrateArtwork(res) return res, err } +// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// heavy stats/annotation columns and JSON post-processing. Used by bulk enumeration (artwork backfill). +func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *artistRepository) hydrateArtwork(artists model.Artists) { + if len(artists) == 0 { + return + } + ids := slice.Map(artists, func(a model.Artist) string { return a.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindArtistArtwork.Prefix(), ids) + for i := range artists { + applyItemImage(infos, artists[i].ID, &artists[i].ItemImage) + } +} + func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { sel := r.selectArtist(options...) cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) @@ -635,7 +658,9 @@ func (r *artistRepository) Search(q string, options ...model.QueryOptions) (mode if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } - return res.toModels(), nil + artists := res.toModels() + r.hydrateArtwork(artists) + return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index dc11ede36..43a7f4928 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -284,6 +284,21 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, a := range want { + wantIDs = append(wantIDs, a.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go new file mode 100644 index 000000000..bf1060055 --- /dev/null +++ b/persistence/artwork_hydration.go @@ -0,0 +1,31 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// hydrateItemImages returns per-item artwork info for a fetched page via one batched query per kind +// (never a join, see spec §6). On error it logs and returns an empty map so the page still renders. +func hydrateItemImages(ctx context.Context, db dbx.Builder, kind string, ids []string) map[string]model.ItemArtworkInfo { + if len(ids) == 0 { + return map[string]model.ItemArtworkInfo{} + } + infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids) + if err != nil { + log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) + return map[string]model.ItemArtworkInfo{} + } + return infos +} + +// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). +func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { + if info, ok := infos[id]; ok { + img.ImageHash = info.Hash + img.ImageAbsent = info.Absent() + } +} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go new file mode 100644 index 000000000..d9c80e054 --- /dev/null +++ b/persistence/artwork_hydration_test.go @@ -0,0 +1,305 @@ +package persistence + +import ( + "context" + + "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/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("Artwork hydration", func() { + var ctx context.Context + var aw model.ArtworkRepository + + putInfo := func(kind, id, hash string) { + Expect(aw.PutItemArtwork(&model.ItemArtwork{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, + })).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + ctx = request.WithUser(log.NewContext(context.Background()), adminUser) + aw = NewArtworkRepository(ctx, GetDBXBuilder()) + }) + + Describe("albums", func() { + var repo model.AlbumRepository + BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("al", albumSgtPeppers.ID, "althash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + // albumRadioactivity: no row -> unresolved + + byID := map[string]model.Album{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range all { + byID[a.ID] = a + } + + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) + Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) + Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("al", albumSgtPeppers.ID, "gethash22222222") + got, err := repo.Get(albumSgtPeppers.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("gethash22222222")) + }) + + It("hydrates Search", func() { + putInfo("al", albumSgtPeppers.ID, "srchash33333333") + res, err := repo.Search("Peppers", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("srchash33333333")) + }) + + It("does not persist ImageHash/ImageAbsent on Put", func() { + al := albumSgtPeppers + al.ImageHash = "shouldnotpersist" + al.ImageAbsent = true + Expect(repo.(*albumRepository).Put(&al)).To(Succeed()) + + // No item_artwork rows exist, so a fresh read must observe zero values. + got, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(BeEmpty()) + Expect(got.ImageAbsent).To(BeFalse()) + }) + }) + + Describe("artists", func() { + var repo model.ArtistRepository + BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("ar", artistBeatles.ID, "arhash444444444") + putInfo("ar", artistKraftwerk.ID, "") + // artistCJK: no row -> unresolved + + byID := map[string]model.Artist{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range all { + byID[a.ID] = a + } + + Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) + Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) + Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) + Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("ar", artistBeatles.ID, "arget5555555555") + got, err := repo.Get(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("arget5555555555")) + }) + + It("hydrates Search", func() { + putInfo("ar", artistBeatles.ID, "arsrch666666666") + res, err := repo.Search("Beatles", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("arsrch666666666")) + }) + }) + + Describe("playlists", func() { + var repo model.PlaylistRepository + BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("pl", plsBest.ID, "plhash777777777") + putInfo("pl", plsCool.ID, "") + + byID := map[string]model.Playlist{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, p := range all { + byID[p.ID] = p + } + + Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) + Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) + Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) + Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("pl", plsBest.ID, "plget8888888888") + got, err := repo.Get(plsBest.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("plget8888888888")) + }) + }) + + Describe("radios", func() { + var repo model.RadioRepository + BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("ra", radioWithHomePage.ID, "rahash999999999") + putInfo("ra", radioWithoutHomePage.ID, "") + + byID := map[string]model.Radio{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, rd := range all { + byID[rd.ID] = rd + } + + Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) + Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) + Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) + Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") + got, err := repo.Get(radioWithHomePage.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) + }) + }) + + Describe("mediafiles", func() { + var repo model.MediaFileRepository + + setCover := func(id string, v bool) { + _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). + Bind(dbx.Params{"v": v, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + getByID := func() map[string]model.MediaFile { + byID := map[string]model.MediaFile{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range all { + byID[mf.ID] = mf + } + return byID + } + + BeforeEach(func() { + repo = NewMediaFileRepository(ctx, GetDBXBuilder()) + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("resolves the embedded-eligible fallback matrix", func() { + setCover("1001", true) // eligible, own hash + setCover("1002", true) // eligible, but embedded art absent -> album + DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) + + putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) + putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) + putInfo("al", "103", "") // songs 1003/1004 album known-absent + putInfo("mf", "1001", "mfh1001xxxxxxxx") + putInfo("mf", "1002", "") // embedded resolved absent + + byID := getByID() + + // eligible + own hash -> own hash + Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + // eligible + embedded absent -> falls through to album 102 info + Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) + Expect(byID["1002"].ImageAbsent).To(BeFalse()) + // not eligible (no embedded cover) -> album 103 info (known-absent) + Expect(byID["1003"].ImageHash).To(BeEmpty()) + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + // not eligible, album has no row -> zero values (unresolved) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { + // 1004 is eligible (has embedded cover) with no mf state row yet; its album (103) is absent. + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "") // album known-absent + + byID := getByID() + + // The track's own embedded art is still unresolved, so it must NOT inherit the album's + // absence: coverArt stays requestable so serving can extract the embedded art. + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + // A non-eligible sibling on the same absent album still inherits the absence. + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + }) + + It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { + putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found + + byID := getByID() + + // 2002 is multi-disc (DiscNumber>0); CoverArtID emits a dc- id served from disc art of + // unknown identity, so it must not advertise the album's hash as its content-version. + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { + putInfo("al", "104", "") // songs 2002/2004 album known-absent + + byID := getByID() + + // 2002 is a multi-disc track (DiscNumber>0); CoverArtID points at disc art, which + // resolves provisionally, so it must never inherit the album's absence. + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + }) + + It("uses the album hash for an eligible file whose own art is unresolved but album is found", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "alh103found11111") + + byID := getByID() + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(Equal("alh103found11111")) + }) + + It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { + conf.Server.EnableMediaFileCoverArt = false + setCover("1001", true) + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101offxxxxxxx") + putInfo("mf", "1001", "mfh1001offxxxxx") + + byID := getByID() + Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Search", func() { + putInfo("al", "101", "alsrchhhhhhhhhhh") + res, err := repo.Search("A Day In A Life", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) + }) + }) +}) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go new file mode 100644 index 000000000..81f674dd9 --- /dev/null +++ b/persistence/artwork_queue_repository.go @@ -0,0 +1,123 @@ +package persistence + +import ( + "context" + "slices" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// enqueueChunkSize keeps each multi-row insert under SQLite's bind-variable limit (7 cols -> 700 vars). +const enqueueChunkSize = 100 + +type artworkQueueRepository struct { + sqlRepository +} + +func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository { + r := &artworkQueueRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork_queue" + return r +} + +func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at`, items) +} + +// EnqueueBump raises priority like Enqueue but leaves an existing row's retry_at intact, so a +// request-triggered read-through never resets a failed resolution's backoff. New rows insert eligible. +func (r *artworkQueueRepository) EnqueueBump(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority)`, items) +} + +func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { + now := time.Now() + for chunk := range slices.Chunk(items, enqueueChunkSize) { + ins := Insert(r.tableName).Columns("item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at") + for _, it := range chunk { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + ins = ins.Values(it.ItemKind, it.ItemID, it.ImageType, it.Priority, 0, now, now) + } + ins = ins.Suffix(conflict) + if _, err := r.executeSQL(ins); err != nil { + return err + } + } + return nil +} + +func (r *artworkQueueRepository) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + sel := Select("*").From(r.tableName). + Where(LtOrEq{"retry_at": time.Now()}). + OrderBy("priority DESC", "enqueued_at ASC"). + Limit(uint64(n)) + var res []model.ArtworkQueueItem + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) MarkFailed(kind, id, imageType string, retryAt time.Time) error { + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) + c, err := r.executeSQL(upd) + if err == nil && c == 0 { + return model.ErrNotFound + } + return err +} + +// MarkFailedIfUnchanged applies the backoff only while retry_at still equals seenRetryAt; +// a concurrent Enqueue resets retry_at, so its fresh eligibility survives untouched. +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) + _, err := r.executeSQL(upd) + return err +} + +func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) +} + +// DeleteIfUnchanged deletes the row only while its retry_at still equals the dequeued +// value; a concurrent Enqueue resets retry_at, so the row survives to be re-resolved. +func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) +} + +// PurgeDangling removes queue rows whose entity no longer exists, per kind. +func (r *artworkQueueRepository) PurgeDangling() (int64, error) { + return purgeDangling(r.executeSQL, r.tableName) +} + +func (r *artworkQueueRepository) Count() (int64, error) { + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) + return res.Count, err +} + +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + now := time.Now() + // DO NOTHING is deliberate: rechecks must not bump priority/retry_at of already-queued items. + ins := Expr(`INSERT INTO `+r.tableName+` (item_kind, item_id, image_type, priority, attempts, retry_at, enqueued_at) + SELECT item_kind, item_id, image_type, ?, 0, ?, ? + FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ? + ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, + model.ArtworkPriorityRecheck, now, now, kind, attemptedBefore) + return r.executeSQL(ins) +} + +var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go new file mode 100644 index 000000000..f5eafec00 --- /dev/null +++ b/persistence/artwork_queue_repository_test.go @@ -0,0 +1,187 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkQueueRepository", func() { + var repo model.ArtworkQueueRepository + + item := func(kind, id string, prio int) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, + ImageType: model.ImageTypePrimary, Priority: prio} + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + }) + + It("enqueues and dequeues by priority then FIFO", func() { + Expect(repo.Enqueue(item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got[0].ItemID).To(Equal("high")) + }) + + It("keeps the higher priority on duplicate enqueue", func() { + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("EnqueueBump raises priority without resetting a backing-off row's retry_at", func() { + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + // Push retry_at into the future so the row is backing off and hidden from dequeue. + Expect(repo.MarkFailed("al", "b1", model.ImageTypePrimary, time.Now().Add(time.Hour))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty()) + + // A request-triggered bump raises priority but must leave the backoff intact. + Expect(repo.EnqueueBump(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at") + + // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") + }) + + It("EnqueueBump inserts a brand-new row eligible immediately", func() { + Expect(repo.EnqueueBump(item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].ItemID).To(Equal("n1")) + }) + + It("hides failed items until retry_at", func() { + Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.MarkFailed("al", "f1", model.ImageTypePrimary, time.Now().Add(time.Hour))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty()) + + Expect(repo.MarkFailed("al", "f1", model.ImageTypePrimary, time.Now().Add(-time.Minute))).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Attempts).To(Equal(2)) + }) + + It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past (attempts -> 1) so it can never collide with the re-enqueue's now. + Expect(repo.MarkFailed("al", "m1", model.ImageTypePrimary, time.Now().Add(-time.Hour))).To(Succeed()) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + + // Failing with the stale retry_at is a no-op: the re-enqueued row keeps its fresh state. + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") + Expect(got[0].Attempts).To(Equal(1), "the stale failure must not bump attempts") + current := got[0].RetryAt + + // Failing with the current retry_at applies the backoff and bumps attempts. + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") + all, _ := repo.Count() + Expect(all).To(Equal(int64(1))) + }) + + It("deletes on completion and counts", func() { + Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + Expect(repo.Delete("al", "c1", model.ImageTypePrimary)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + Expect(repo.MarkFailed("al", "d1", model.ImageTypePrimary, time.Now().Add(-time.Hour))).To(Succeed()) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + + // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, original)).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + + // Deleting with the current retry_at removes it. + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("purges queue rows whose entity no longer exists, per kind", func() { + Expect(repo.Enqueue( + item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), + item("al", "no-such-album", model.ArtworkPriorityScan), + item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), + item("ar", "no-such-artist", model.ArtworkPriorityScan), + item("pl", plsBest.ID, model.ArtworkPriorityScan), + item("pl", "no-such-playlist", model.ArtworkPriorityScan), + item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), + item("ra", "no-such-radio", model.ArtworkPriorityScan), + item("mf", songDayInALife.ID, model.ArtworkPriorityScan), + item("mf", "no-such-mediafile", model.ArtworkPriorityScan), + )).To(Succeed()) + + purged, err := repo.PurgeDangling() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + got, _ := repo.DequeueBatch(100) + ids := make([]string, 0, len(got)) + for _, it := range got { + ids = append(ids, it.ItemID) + } + Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) + }) + + It("enqueues stale absent states for recheck", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + old := time.Now().Add(-48 * time.Hour) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) + + n, err := repo.EnqueueStaleAbsent("ar", time.Now().Add(-24*time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + items, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(items).To(HaveLen(1)) + Expect(items[0].ItemID).To(Equal("stale1")) + Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) + }) +}) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go new file mode 100644 index 000000000..e21a69c63 --- /dev/null +++ b/persistence/artwork_repository.go @@ -0,0 +1,218 @@ +package persistence + +import ( + "context" + "slices" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +const ( + itemArtworkTable = "item_artwork" + artworkBatchSize = 200 +) + +type itemArtworkSQL struct { + sqlRepository +} + +type artworkRepository struct { + sqlRepository + items itemArtworkSQL +} + +func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { + r := &artworkRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork" + r.items.ctx = ctx + r.items.db = db + r.items.tableName = itemArtworkTable + return r +} + +func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { + sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) + var res model.Artwork + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutImage(a *model.Artwork) error { + // created_at is the last-acquisition-write time the prune grace window keys on. + a.CreatedAt = time.Now() + values, err := toSQLArgs(*a) + if err != nil { + return err + } + // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. + ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, created_at=excluded.created_at`) + _, err = r.executeSQL(ins) + return err +} + +func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork, error) { + res := map[string]model.Artwork{} + for chunk := range slices.Chunk(hashes, artworkBatchSize) { + sel := Select("*").From(r.tableName).Where(Eq{"hash": chunk}) + var all []model.Artwork + if err := r.queryAll(sel, &all); err != nil { + return nil, err + } + for _, a := range all { + res[a.Hash] = a + } + } + return res, nil +} + +func (r *artworkRepository) GetAllMimes() (map[string]string, error) { + sel := Select("hash", "mime").From(r.tableName) + var rows []struct { + Hash string + Mime string + } + if err := r.queryAll(sel, &rows); err != nil { + return nil, err + } + res := make(map[string]string, len(rows)) + for _, row := range rows { + res[row.Hash] = row.Mime + } + return res, nil +} + +func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { + sel := Select("hash").From(r.tableName). + Where(And{ + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), + }) + var hashes []string + err := r.queryAllSlice(sel, &hashes) + return hashes, err +} + +func (r *artworkRepository) DeleteOrphans(createdBefore time.Time, hashes []string) error { + for chunk := range slices.Chunk(hashes, artworkBatchSize) { + del := Delete(r.tableName).Where(And{ + Eq{"hash": chunk}, + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), + }) + if _, err := r.executeSQL(del); err != nil { + return err + } + } + return nil +} + +// danglingItemArtworkKinds maps item_kind prefixes to the table that owns the entity. +var danglingItemArtworkKinds = map[string]string{ + "al": "album", + "ar": "artist", + "pl": "playlist", + "ra": "radio", + "mf": "media_file", +} + +// purgeDangling deletes rows in table whose owning entity is gone, one statement per kind. +func purgeDangling(execute func(Sqlizer) (int64, error), table string) (int64, error) { + var total int64 + for kind, entityTable := range danglingItemArtworkKinds { + del := Delete(table).Where(And{ + Eq{"item_kind": kind}, + Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), + }) + c, err := execute(del) + if err != nil { + return total, err + } + total += c + } + return total, nil +} + +func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) { + return purgeDangling(r.items.executeSQL, itemArtworkTable) +} + +func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { + sel := Select("*").From(itemArtworkTable). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) + var res model.ItemArtwork + if err := r.items.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } + ia.UpdatedAt = time.Now() + // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + values, err := toSQLArgs(*ia) + if err != nil { + return err + } + ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, + attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) + _, err = r.items.executeSQL(ins) + return err +} + +func (r *artworkRepository) DeleteForItem(kind, id string) error { + return r.items.delete(Eq{"item_kind": kind, "item_id": id}) +} + +func (r *artworkRepository) DeleteForItems(kind string, ids []string) error { + for chunk := range slices.Chunk(ids, artworkBatchSize) { + if err := r.items.delete(Eq{"item_kind": kind, "item_id": chunk}); err != nil { + return err + } + } + return nil +} + +func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + res := map[string]model.ItemArtworkInfo{} + for chunk := range slices.Chunk(ids, artworkBatchSize) { + sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash"). + From(itemArtworkTable + " ia"). + LeftJoin("artwork a ON a.hash = ia.hash"). + Where(And{ + Eq{"ia.item_kind": kind}, + Eq{"ia.image_type": model.ImageTypePrimary}, + Eq{"ia.item_id": chunk}, + }) + var rows []struct { + ItemID string + Hash string + BlurHash string + } + if err := r.items.queryAll(sel, &rows); err != nil { + return nil, err + } + for _, row := range rows { + res[row.ItemID] = model.ItemArtworkInfo{ + ItemID: row.ItemID, Hash: row.Hash, BlurHash: row.BlurHash, + } + } + } + return res, nil +} + +var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go new file mode 100644 index 000000000..c1f1ad741 --- /dev/null +++ b/persistence/artwork_repository_test.go @@ -0,0 +1,259 @@ +package persistence + +import ( + "context" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +// clearArtworkTables resets the shared test DB's artwork tables so specs don't leak state. +func clearArtworkTables() { + db := GetDBXBuilder() + for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { + _, err := db.NewQuery("DELETE FROM " + t).Execute() + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("ArtworkRepository", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + }) + + Context("image identity", func() { + It("stores and retrieves an artwork by hash", func() { + a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("abc123") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Mime).To(Equal("image/jpeg")) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.PutImage(a)).To(Succeed()) + a.BlurHash = "XYZ" + Expect(repo.PutImage(a)).To(Succeed()) + got, _ := repo.GetImage("dup1") + Expect(got.BlurHash).To(Equal("XYZ")) + }) + + It("refreshes created_at when reacquiring an existing hash", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) + + got, err := repo.GetImage("reacq") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("returns ErrNotFound for a missing hash", func() { + _, err := repo.GetImage("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("fetches a batch", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "b1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "b2", Mime: "image/png"})).To(Succeed()) + got, err := repo.GetImages([]string{"b1", "b2", "missing"}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got["b2"].Mime).To(Equal("image/png")) + }) + + It("returns every stored hash with its current mime", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) + mimes, err := repo.GetAllMimes() + Expect(err).ToNot(HaveOccurred()) + Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) + Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) + }) + + It("finds orphans older than cutoff, honoring item_artwork references", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "orph1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "ref1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: "ref1", Source: "folder"})).To(Succeed()) + + orphans, err := repo.GetOrphanHashes(time.Now().Add(time.Minute)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(ContainElement("orph1")) + Expect(orphans).ToNot(ContainElement("ref1")) + + orphans, err = repo.GetOrphanHashes(time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(BeEmpty()) + }) + + It("deletes only unreferenced hashes older than the cutoff", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) + + Expect(repo.DeleteOrphans(time.Now().Add(time.Minute), []string{"d1", "dref"})).To(Succeed()) + + _, err := repo.GetImage("d1") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.GetImage("dref") + Expect(err).ToNot(HaveOccurred()) + }) + + It("spares an unreferenced hash younger than the cutoff", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.DeleteOrphans(time.Now().Add(-time.Hour), []string{"young"})).To(Succeed()) + _, err := repo.GetImage("young") + Expect(err).ToNot(HaveOccurred()) + }) + + It("fetches a batch larger than the SQL variable limit", func() { + hashes := make([]string, 0, 250) + for i := range 250 { + h := fmt.Sprintf("big%03d", i) + Expect(repo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + hashes = append(hashes, h) + } + hashes = append(hashes, "absent1", "absent2") + + got, err := repo.GetImages(hashes) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(250)) + }) + }) + + Context("dangling state cleanup", func() { + It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) + + purged, err := repo.PurgeDanglingItemArtwork() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + for _, kept := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: albumSgtPeppers.ID}, + {ItemKind: "ar", ItemID: artistKraftwerk.ID}, + {ItemKind: "pl", ItemID: plsBest.ID}, + {ItemKind: "ra", ItemID: radioWithHomePage.ID}, + {ItemKind: "mf", ItemID: songDayInALife.ID}, + } { + _, err := repo.GetItemArtwork(kept.ItemKind, kept.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + } + for _, gone := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: "no-such-album"}, + {ItemKind: "ar", ItemID: "no-such-artist"}, + {ItemKind: "pl", ItemID: "no-such-playlist"}, + {ItemKind: "ra", ItemID: "no-such-radio"}, + {ItemKind: "mf", ItemID: "no-such-mediafile"}, + } { + _, err := repo.GetItemArtwork(gone.ItemKind, gone.ItemID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + }) + }) + + Context("item state", func() { + It("upserts and reads state, including per-item provenance", func() { + ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, + Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + ia.Source = "embedded" + ia.SourcePath = "/music/a/track.mp3" + ia.RefMtime = 222 + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + + got, err := repo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Source).To(Equal("embedded")) + Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) + Expect(got.RefMtime).To(Equal(int64(222))) + Expect(got.UpdatedAt).ToNot(BeZero()) + }) + + It("defaults attempted_at to now when unset", func() { + before := time.Now().Add(-time.Second) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", + ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) + got, err := repo.GetItemArtwork("ar", "noattempt", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AttemptedAt).To(BeTemporally(">", before)) + }) + + It("represents known-absent as empty hash", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", + ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + got, err := repo.GetItemArtwork("ar", "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Hash).To(BeEmpty()) + }) + + It("hydrates a page in one batch, including blurhash and absence", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) + + info, err := repo.GetInfoForItems("al", []string{"x1", "x2", "x3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(info).To(HaveLen(2)) + Expect(info["x1"].Hash).To(Equal("h9")) + Expect(info["x1"].BlurHash).To(Equal("BH9")) + Expect(info["x1"].Absent()).To(BeFalse()) + Expect(info["x2"].Absent()).To(BeTrue()) + _, unresolved := info["x3"] + Expect(unresolved).To(BeFalse()) + }) + + It("deletes all rows for an item", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.DeleteForItem("pl", "p1")).To(Succeed()) + _, err := repo.GetItemArtwork("pl", "p1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("deletes rows for many items in chunks, leaving others untouched", func() { + const n = artworkBatchSize + 5 + ids := make([]string, n) + for i := range n { + id := fmt.Sprintf("mf-%d", i) + ids[i] = id + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + } + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + + Expect(repo.DeleteForItems("mf", ids)).To(Succeed()) + + for _, id := range ids { + _, err := repo.GetItemArtwork("mf", id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + kept, err := repo.GetItemArtwork("mf", "keep", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.ItemID).To(Equal("keep")) + }) + }) +}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index 1ff2139e6..29291f66e 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -14,7 +14,6 @@ import ( "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" @@ -275,8 +274,8 @@ var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, initDS, events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService(initDS)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..4cc93f4dd 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -218,7 +218,58 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media if err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil +} + +// hydrateArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own art uses +// it, else it falls back to the album's. Two batched item_artwork lookups per page, never a join. +func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { + if len(mfs) == 0 { + return + } + albumIDs := make([]string, len(mfs)) + var eligibleIDs []string + for i := range mfs { + albumIDs[i] = mfs[i].AlbumID + if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { + eligibleIDs = append(eligibleIDs, mfs[i].ID) + } + } + albumInfos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), albumIDs) + mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork.Prefix(), eligibleIDs) + for i := range mfs { + mf := &mfs[i] + eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt + ownInfo, ownResolved := mfInfos[mf.ID] + if eligible && ownResolved && !ownInfo.Absent() { + mf.ImageHash = ownInfo.Hash // own resolved art wins + continue + } + // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching, + // but only for a single-disc track. A multi-disc track emits a dc- id served from + // disc-specific art of unknown identity, so stamping the album hash would advertise a + // wrong content-version; leave it bare (the served response still carries a correct ETag). + if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { + if mf.DiscNumber == 0 { + mf.ImageHash = album.Hash + } + continue + } + // Nothing found. Mark absent only when serving would definitively yield a placeholder: + // a single-disc track whose album is known-absent and whose own art won't resolve. A + // multi-disc track resolves disc art provisionally (never known-absent), and an + // eligible-but-unresolved track can still extract its own embedded art — both stay + // requestable. + if mf.DiscNumber > 0 { + continue + } + ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) + if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { + mf.ImageAbsent = true + } + } } // GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the @@ -252,7 +303,9 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me if err := r.queryAll(sq, &res); err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { @@ -487,7 +540,9 @@ func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (m if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/persistence.go b/persistence/persistence.go index 93f0e3e71..9d3a33cfc 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -97,6 +97,14 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { return NewPluginRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) Artwork(ctx context.Context) model.ArtworkRepository { + return NewArtworkRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index c78e0df1e..9942432fa 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -14,6 +14,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -131,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { if len(pls.Tracks) > 0 { return r.updateTracks(id, p.MediaFiles()) } + pls.ID = id // r.put assigns the generated id to p, not to this copy; refreshCounters enqueues by it return r.refreshCounters(&pls.Playlist) } @@ -172,7 +174,21 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { return nil, model.ErrNotFound } - return &pls[0].Playlist, nil + list := model.Playlists{pls[0].Playlist} + r.hydrateArtwork(list) + return &list[0], nil +} + +// hydrateArtwork fills each playlist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) { + if len(playlists) == 0 { + return + } + ids := slice.Map(playlists, func(p model.Playlist) string { return p.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindPlaylistArtwork.Prefix(), ids) + for i := range playlists { + applyItemImage(infos, playlists[i].ID, &playlists[i].ItemImage) + } } func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { @@ -186,9 +202,20 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, err } +// GetAllIDs returns just the playlist IDs for the same row set as GetAll (honoring userFilter), +// skipping the owner-name join columns and annotation. Used by bulk enumeration (artwork backfill). +func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.newSelect(options...).Columns("playlist.id"). + Join("user on user.id = owner_id").Where(r.userFilter()) + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. sel := r.selectPlaylist(options...).Where(r.userFilter()) @@ -220,6 +247,7 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, nil } @@ -297,6 +325,13 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + // The generated 2x2 grid depends on the track set, so re-resolve the cover whenever it + // changes. No clear: the old cover keeps serving until the worker rebuilds (no flicker). + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", pls.ID, err) + } return nil } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 00d4ff9f2..9905d3da6 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -37,6 +37,21 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, p := range want { + wantIDs = append(wantIDs, p.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) @@ -245,6 +260,34 @@ var _ = Describe("PlaylistRepository", func() { Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) + It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path + Expect(repo.Put(&newPls)).To(Succeed()) + Expect(newPls.ID).ToNot(BeEmpty()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") + }) + + It("enqueues the playlist's artwork when its track set changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002"}) + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", newPls.ID), + ))) + }) + Describe("GetAll", func() { It("returns all playlists from DB", func() { all, err := repo.GetAll() diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index a073643db..ac3950005 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -7,8 +7,10 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -48,14 +50,43 @@ func (r *radioRepository) Get(id string) (*model.Radio, error) { sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} err := r.queryOne(sel, &res) - return &res, err + if err != nil { + return &res, err + } + list := model.Radios{res} + r.hydrateArtwork(list) + return &list[0], nil } func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { sel := r.newSelect(options...).Columns("*") res := model.Radios{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return res, err + } + r.hydrateArtwork(res) + return res, nil +} + +// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *radioRepository) hydrateArtwork(radios model.Radios) { + if len(radios) == 0 { + return + } + ids := slice.Map(radios, func(rd model.Radio) string { return rd.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindRadioArtwork.Prefix(), ids) + for i := range radios { + applyItemImage(infos, radios[i].ID, &radios[i].ItemImage) + } +} + +// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). +func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sel := r.newSelect(options...).Columns("id") + ids := []string{} + err := r.queryAllSlice(sel, &ids) + return ids, err } func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { @@ -72,7 +103,17 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error colsToUpdate = append(colsToUpdate, "UpdatedAt") } _, err := r.put(radio.ID, radio, colsToUpdate...) - return err + if err != nil { + return err + } + // Enqueue artwork resolution for the created/updated radio at Bump priority so a new + // radio's cover resolves proactively. Never fails the save. + item := model.ArtworkQueueItem{ItemKind: "ra", ItemID: radio.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) + } + return nil } func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 05628ca41..1ea8b971c 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -78,6 +78,21 @@ var _ = Describe("RadioRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, r := range want { + wantIDs = append(wantIDs, r.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).To(BeNil()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ @@ -107,6 +122,27 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) + + It("enqueues artwork resolution for the saved radio", func() { + err := repo.Put(&model.Radio{ + Name: "Artwork radio", + StreamUrl: "https://example.com:4533/artwork", + }) + Expect(err).To(BeNil()) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + created := all[len(all)-1] + + queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).To(BeNil()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ra"), + HaveField("ItemID", created.ID), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) }) }) diff --git a/scanner/controller.go b/scanner/controller.go index 463718ba3..df5aeb6f9 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -28,12 +27,11 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, +func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, - cw: cw, broker: broker, pls: pls, metrics: m, @@ -49,7 +47,7 @@ func (s *controller) getScanner() scanner { if s.devExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} + return &scannerImpl{ds: s.ds, pls: s.pls} } // CallScan starts an in-process scan of specific library/folder pairs. @@ -66,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} + scanner := &scannerImpl{ds: ds, pls: pls} scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil @@ -97,7 +95,6 @@ type scanner interface { type controller struct { rootCtx context.Context ds model.DataStore - cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics pls playlists.Playlists diff --git a/scanner/controller_test.go b/scanner/controller_test.go index e4814da64..3dbb36eeb 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "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/db" @@ -32,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..f55f975dc 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -16,7 +16,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -26,7 +25,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { var jobs []*scanJob // Create scan jobs for all libraries @@ -37,7 +36,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) + job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) @@ -52,14 +51,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor type scanJob struct { lib model.Library fs storage.MusicFS - cw artwork.CacheWarmer lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library targetFolders []string // Specific folders to scan (including all descendants) lock sync.Mutex numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { +func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { @@ -85,7 +83,6 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, return &scanJob{ lib: lib, fs: fsys, - cw: cw, lastUpdates: lastUpdates, targetFolders: targetFolders, }, nil @@ -330,8 +327,8 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - // Collect artwork IDs to pre-cache after the transaction commits - var artworkIDs []model.ArtworkID + // Collect artwork queue items for changed albums/artists, enqueued in the same transaction + var queueItems []model.ArtworkQueueItem err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder @@ -371,7 +368,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: "ar", ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -383,7 +383,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: "al", ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -396,6 +399,17 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } } + // A re-imported track returns to unresolved so new embedded art is picked up lazily. + if len(entry.tracks) > 0 { + trackIDs := make([]string, len(entry.tracks)) + for i := range entry.tracks { + trackIDs[i] = entry.tracks[i].ID + } + if err := tx.Artwork(p.ctx).DeleteForItems("mf", trackIDs); err != nil { + log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err) + } + } + // Mark all missing tracks as not available if len(entry.missingTracks) > 0 { err = mfRepo.MarkMissing(true, entry.missingTracks...) @@ -415,19 +429,19 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } } + + // Enqueue artwork resolution for changed albums/artists. Never fails the scan. + if len(queueItems) > 0 { + if err := tx.ArtworkQueue(p.ctx).Enqueue(queueItems...); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err) + } + } return nil }, "scanner: persist changes") if err != nil { log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } - // Pre-cache artwork after the transaction commits successfully - if err == nil { - for _, artID := range artworkIDs { - entry.job.cw.PreCache(artID) - } - } - return entry, err } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 8ba014235..04ad2c276 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -12,7 +12,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,18 +23,16 @@ type phasePlaylists struct { scanState *scanState ds model.DataStore pls playlists.Playlists - cw artwork.CacheWarmer refreshed atomic.Uint32 pendingImport bool } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, - cw: cw, } } @@ -148,7 +145,11 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } else { log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } - p.cw.PreCache(pls.CoverArtID()) + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) + } p.refreshed.Add(1) } return folder, nil diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 49ffc7fb7..93ec1a36d 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -10,7 +10,6 @@ import ( "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/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -27,7 +26,6 @@ var _ = Describe("phasePlaylists", func() { folderRepo *mockFolderRepository ds *tests.MockDataStore pls *mockPlaylists - cw artwork.CacheWarmer ) var userRepo *tests.MockedUserRepo @@ -48,9 +46,8 @@ var _ = Describe("phasePlaylists", func() { MockedProperty: propRepo, } pls = &mockPlaylists{} - cw = artwork.NoopCacheWarmer() state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls, cw) + phase = createPhasePlaylists(ctx, state, ds, pls) }) Describe("description", func() { @@ -193,6 +190,29 @@ var _ = Describe("phasePlaylists", func() { Expect(phase.refreshed.Load()).To(Equal(uint32(2))) }) + It("enqueues artwork resolution for the imported playlist", func() { + libPath := GinkgoT().TempDir() + folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} + _ = os.MkdirAll(folder.AbsolutePath(), 0755) + + file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") + _ = os.WriteFile(file1, []byte{}, 0600) + + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). + Return(&model.Playlist{ID: "pl1"}, nil) + + _, err := phase.processPlaylistsInFolder(folder) + Expect(err).ToNot(HaveOccurred()) + + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pl1"), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + }) + It("reports an error if there is an error reading files", func() { tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) diff --git a/scanner/scanner.go b/scanner/scanner.go index 27e2b19d2..8f47c0b7d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,7 +11,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,7 +20,6 @@ import ( type scannerImpl struct { ds model.DataStore - cw artwork.CacheWarmer pls playlists.Playlists } @@ -136,7 +134,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -147,7 +145,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), + runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), ), // Final Steps (cannot be parallelized): diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 8f0dcd340..0abac5f40 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "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" @@ -40,8 +39,8 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(context.Background(), ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 3ae50933c..e6650f39d 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "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" @@ -78,8 +77,8 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 17772bf9d..ca87a5f6b 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "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" @@ -66,8 +65,8 @@ var _ = Describe("ScanFolders", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index cc3720717..48eb2fedd 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "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" @@ -86,8 +85,8 @@ var _ = Describe("Scanner", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) @@ -152,6 +151,29 @@ var _ = Describe("Scanner", Ordered, func() { HaveField("SongCount", Equal(4)), )) }) + It("should enqueue artwork resolution for the scanned albums and artists", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + albums, _ := ds.Album(ctx).GetAll() + artists, _ := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + + for _, al := range albums { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", al.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + for _, ar := range artists { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", ar.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + }) }) When("a file was changed", func() { It("should update the media_file", func() { @@ -187,6 +209,26 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) }) + + It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(mf).ToNot(BeEmpty()) + trackID := mf[0].ID + + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, + Source: "embedded", Hash: "stalehash", + })).To(Succeed()) + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) + Expect(runScanner(ctx, true)).To(Succeed()) + + _, err = ds.Artwork(ctx).GetItemArtwork("mf", trackID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) }) }) diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go new file mode 100644 index 000000000..e7bb6d562 --- /dev/null +++ b/server/imghttp/headers.go @@ -0,0 +1,68 @@ +// Package imghttp holds the shared HTTP caching contract for artwork responses, so the +// subsonic, public, and jellyfin image handlers apply identical headers without importing +// each other. +package imghttp + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/core/artwork" +) + +// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written +// (in which case the caller must not write a body). requestedHash is the hash the client asserted +// (id suffix / JWT payload / jellyfin tag param), or "" when the request carried no hash. +func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { + h := w.Header() + // Placeholders are transient stand-ins for not-yet-resolved art: never cached, no validators. + if img.Placeholder { + h.Set("Cache-Control", "no-store") + return false + } + + // The validator identifies the served representation (resized/re-encoded bytes version it via + // ETag), so a CoverArtQuality/EnableWebPEncoding change invalidates a revalidating client's + // cache. Falls back to the pixel hash for full-size originals (bytes == the hash). + etag := img.ETag + if etag == "" { + etag = img.Hash + } + h.Set("ETag", `"`+etag+`"`) + if !img.LastUpdated.IsZero() { + h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) + } + // Immutable only when the client asked for the exact current pixel hash; bare/legacy/mismatched + // requests get cheap ETag revalidation instead, which fixes stale art after re-resolution. + if requestedHash != "" && requestedHash == img.Hash { + h.Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + h.Set("Cache-Control", "public, no-cache") + } + + if ifNoneMatch(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return true + } + return false +} + +// ifNoneMatch reports whether the If-None-Match header asserts the given hash, using weak +// comparison (RFC 9110): "*" matches any current representation and W/ prefixes are ignored. +func ifNoneMatch(header, hash string) bool { + header = strings.TrimSpace(header) + if header == "" { + return false + } + if header == "*" { + return true + } + for _, tag := range strings.Split(header, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "W/") + if strings.Trim(tag, `"`) == hash { + return true + } + } + return false +} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go new file mode 100644 index 000000000..52a549556 --- /dev/null +++ b/server/imghttp/headers_test.go @@ -0,0 +1,105 @@ +package imghttp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/server/imghttp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testHash = "0123456789abcdef" +const testRepTag = testHash + ".300.false.q75" + +var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + +func found() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + LastUpdated: lastMod, + } +} + +func placeholder() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} +} + +// resized carries a representation ETag distinct from the pixel hash (as a resized/re-encoded +// response does), so the validator versions with the encode settings. +func resized() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + ETag: testRepTag, + LastUpdated: lastMod, + } +} + +var _ = Describe("WriteImageHeaders", func() { + type testCase struct { + img *artwork.Image + requestedHash string + ifNoneMatch string + want304 bool + wantCache string + wantETag string + wantLastMod bool + } + + DescribeTable("applies the artwork caching contract", + func(c testCase) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/img", nil) + if c.ifNoneMatch != "" { + r.Header.Set("If-None-Match", c.ifNoneMatch) + } + + Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) + + h := w.Header() + Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) + Expect(h.Get("ETag")).To(Equal(c.wantETag)) + if c.img.Placeholder { + Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") + Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") + } + Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) + if c.want304 { + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") + } + }, + Entry("placeholder is never cached and carries no validators", + testCase{img: placeholder(), wantCache: "no-store"}), + Entry("found with matching requested hash is immutable", + testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with bare id revalidates via no-cache", + testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with mismatched requested hash revalidates", + testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("resized keeps pixel-hash immutable but serves the representation ETag", + testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized 304s on the representation ETag, not the pixel hash", + testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized does not 304 on a stale pixel-hash validator (config changed)", + testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("If-None-Match matching the hash yields 304", + testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("weak If-None-Match matches (weak comparison)", + testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match with multiple values matches one", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match star matches any current representation", + testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("non-matching If-None-Match serves body", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("placeholder ignores If-None-Match and never 304s", + testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), + ) +}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go new file mode 100644 index 000000000..7a747db5b --- /dev/null +++ b/server/imghttp/imghttp_suite_test.go @@ -0,0 +1,17 @@ +package imghttp_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestImgHTTP(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ImgHTTP Suite") +} diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1f46c08b4..f7a38feb4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -30,7 +30,7 @@ import ( type Router struct { http.Handler ds model.DataStore - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer transcodeDecider stream.TranscodeDecider players core.Players @@ -46,7 +46,7 @@ type Router struct { serverIDVal string } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 31d98d1d9..e7ea172e3 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -33,7 +33,6 @@ import ( "strings" "testing" "testing/fstest" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -327,7 +326,7 @@ func setupTestDB() { decider, core.NewPlayers(ds), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), @@ -406,18 +405,18 @@ type spyArtwork struct { data []byte } -func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) { s.lastID = id s.lastCtx = c d := s.data if d == nil { d = []byte("IMG") } - return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil } -var _ artwork.Artwork = &spyArtwork{} +var _ artwork.Service = &spyArtwork{} diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 0ec34f491..35042e9c4 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -32,7 +33,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) - reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): return @@ -41,9 +42,27 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not Found", http.StatusNotFound) return } - defer reader.Close() + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { + return + } // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, reader) + _, _ = io.Copy(w, img) +} + +// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content +// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. +func hashFromTag(r *http.Request) string { + tag := r.URL.Query().Get("tag") + if len(tag) != 16 { + return "" + } + for _, c := range tag { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return "" + } + } + return tag } // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index e435b99fd..65285e3c5 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -29,20 +29,25 @@ import ( ) type fakeArtwork struct { - artwork.Artwork + artwork.Service recvId string recvCtx context.Context data []byte + hash string } -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id f.recvCtx = ctx data := f.data if data == nil { data = []byte("IMG") } - return io.NopCloser(bytes.NewReader(data)), time.Now(), nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader(data)), + Hash: f.hash, + LastUpdated: time.Now(), + }, nil } func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { @@ -115,6 +120,38 @@ var _ = Describe("Images", func() { Expect(ok).To(BeTrue()) Expect(u.IsAdmin).To(BeTrue()) }) + + It("serves immutable when the tag param asserts the current hash", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + q := r.URL.Query() + q.Set("tag", hash) + r.URL.RawQuery = q.Encode() + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + + It("revalidates via no-cache when no tag is provided", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) }) // Real image fixtures: postItemImage validates uploads by decoding them. diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index daa918d00..193f88eda 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -46,7 +46,11 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { } ar.UploadedImage = filename ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { } ar.UploadedImage = "" ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go new file mode 100644 index 000000000..0a07014be --- /dev/null +++ b/server/nativeapi/artwork.go @@ -0,0 +1,49 @@ +package nativeapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// refreshableArtworkKinds are the entity kinds a manual re-resolve accepts. +var refreshableArtworkKinds = map[string]bool{ + model.KindAlbumArtwork.Prefix(): true, + model.KindArtistArtwork.Prefix(): true, + model.KindPlaylistArtwork.Prefix(): true, + model.KindRadioArtwork.Prefix(): true, + model.KindMediaFileArtwork.Prefix(): true, +} + +func (api *Router) addArtworkRoute(r chi.Router) { + r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) +} + +// refreshArtwork clears an item's resolved artwork state and re-queues it at Bump priority. +// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). +func (api *Router) refreshArtwork() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind := chi.URLParam(r, "kind") + id := chi.URLParam(r, "id") + if !refreshableArtworkKinds[kind] { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if err := api.ds.Artwork(ctx).DeleteForItem(kind, id); err != nil { + log.Error(ctx, "Error clearing artwork state", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := api.ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + log.Error(ctx, "Error enqueuing artwork refresh", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go new file mode 100644 index 000000000..c84e83fdc --- /dev/null +++ b/server/nativeapi/artwork_test.go @@ -0,0 +1,95 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/artwork/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork("al", "al-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", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..3f38e2fe5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -91,6 +91,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addArtworkRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..3e88af287 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -47,7 +47,11 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 50f9238e5..9bcaa5933 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/utils/req" ) @@ -40,7 +41,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) + img, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return @@ -58,10 +59,11 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer imgReader.Close() - w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) - cnt, err := io.Copy(w, imgReader) + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { + return + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 6895241f6..edcf9d8c0 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -1,8 +1,17 @@ package public import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,3 +32,49 @@ var _ = Describe("decodeArtworkID", func() { Expect(err).To(HaveOccurred()) }) }) + +type fakeArtwork struct { + artwork.Service + img *artwork.Image + err error +} + +func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return f.img, f.err +} + +var _ = Describe("handleImages", func() { + var w *httptest.ResponseRecorder + + newImageRequest := func(claimID string) *http.Request { + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) + return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) + } + + BeforeEach(func() { + w = httptest.NewRecorder() + }) + + It("returns 404 when the artwork is unavailable", func() { + pub := &Router{artwork: &fakeArtwork{err: artwork.ErrUnavailable}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the artwork is not found", func() { + pub := &Router{artwork: &fakeArtwork{err: model.ErrNotFound}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("serves the image immutable when the token asserts the current hash", func() { + const hash = "0123456789abcdef" + img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} + pub := &Router{artwork: &fakeArtwork{img: img}} + pub.handleImages(w, newImageRequest("al-1_"+hash)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) +}) diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..35155ec59 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -18,7 +18,7 @@ import ( type Router struct { http.Handler - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer archiver core.Archiver share core.Share @@ -26,7 +26,7 @@ type Router struct { ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..d57da7452 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -40,7 +40,7 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler ds model.DataStore - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer archiver core.Archiver players core.Players @@ -57,7 +57,7 @@ type Router struct { sonic *sonicsvc.Sonic } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index f6a7047c4..581ffb157 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -230,9 +230,11 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + if !album.ImageAbsent { + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + } response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -295,9 +297,11 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + if !artist.ImageAbsent { + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID @@ -453,7 +457,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.AverageRating = album.AverageRating } dir.SongCount = int32(album.SongCount) - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..d34da2f37 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -157,4 +158,57 @@ var _ = Describe("Browsing", func() { Expect(response.Artist).ToNot(BeNil()) }) }) + + Describe("GetAlbumInfo", func() { + It("emits image URLs when the album artwork is unresolved", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the album artwork is known absent", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) + }) + }) + + Describe("GetArtistInfo", func() { + It("emits image URLs when the artist artwork is unresolved", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the artist artwork is known absent", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) + }) + }) }) + +type fakeInfoProvider struct { + external.Provider + album *model.Album + artist *model.Artist +} + +func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return f.album, nil +} + +func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return f.artist, nil +} diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 6875b6370..af15f17df 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -308,15 +308,15 @@ func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { // --- Noop stub implementations for Router dependencies --- -// noopArtwork implements artwork.Artwork +// noopArtwork implements artwork.Service type noopArtwork struct{} -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { - return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { + return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil } // noopArchiver implements core.Archiver @@ -357,17 +357,9 @@ func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, 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 -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} + _ artwork.Service = noopArtwork{} _ core.Archiver = noopArchiver{} _ external.Provider = noopProvider{} ) @@ -416,8 +408,8 @@ func setupTestDB() { // Create the Subsonic Router with real DS, streamer spy, and real Decider streamerSpy = &harness.SpyStreamer{} decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -427,7 +419,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go new file mode 100644 index 000000000..6c92f271b --- /dev/null +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -0,0 +1,307 @@ +package e2e + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + "github.com/Masterminds/squirrel" + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "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/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "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/storage/local" // registers the "file" storage scheme + "github.com/navidrome/navidrome/core/stream" + "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/resources" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" + "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" +) + +// The artwork serving path streams folder-backed originals via os.Open, which a fake FS cannot +// back, so this suite scans a small REAL on-disk library and drives the real acquisition worker +// and artwork.Service through the Subsonic and public image handlers. +var _ = Describe("Artwork Serving", Ordered, func() { + var ( + artRouter *subsonic.Router + pubRouter *public.Router + artSvc artwork.Service + worker *artwork.Worker + artfulID string + artlessID string + artfulHash string + placeholder []byte + ) + + albumCoverInList := func(albumID string) string { + GinkgoHelper() + resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") + Expect(resp.AlbumList2).ToNot(BeNil()) + for _, al := range resp.AlbumList2.Album { + if al.Id == albumID { + return al.CoverArt + } + } + Fail("album " + albumID + " not found in getAlbumList2") + return "" + } + + getCover := func(params ...string) *httptest.ResponseRecorder { + GinkgoHelper() + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", params...) + artRouter.ServeHTTP(w, r) + return w + } + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + musicDir := GinkgoT().TempDir() + writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) + writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) + + conf.Server.MusicFolder = musicDir + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here + conf.Server.EnableMediaFileCoverArt = false + conf.Server.ArtworkWorkerConcurrency = 1 + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableSharing = true + conf.Server.DevArtworkMaxRequests = 100 + conf.Server.DevArtworkThrottleBacklogLimit = 100 + conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute + + goldenDB.Restore() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // A re-scan of already-populated data deadlocks the parallel album-refresh phase at the + // harness's MaxOpenConns=1, so wipe the golden content and import ONLY the real artwork + // library fresh (the import path never enters that refresh phase). + wipeScannedContent() + artLib := model.Library{Name: "Artwork Library", Path: musicDir} + Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Focus the worker on the entities under test. + _, err = db.Db().Exec("DELETE FROM artwork_queue") + Expect(err).ToNot(HaveOccurred()) + + artfulID = albumIDByName("Artful Album") + artlessID = albumIDByName("Artless Album") + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + placeholder, err = io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + _ = ph.Close() + + store := artwork.NewImageStore(GinkgoT().TempDir()) + imgCache := newDummyImageCache(ctx) + ffm := harness.NoopFFmpeg{} + artSvc = artwork.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + + artRouter = buildArtworkRouter(artSvc) + router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router + pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + }) + + It("emits a bare optimistic coverArt id before the queue is drained", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) + Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) + }) + + It("drains the queue: folder art is acquired, the artless album settles absent", func() { + worker.Bump("al", artfulID) + worker.Bump("al", artlessID) + runWorkerUntil(ctx, worker, func() bool { + found, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + if err != nil || found.Hash == "" { + return false + } + absent, err := ds.Artwork(ctx).GetItemArtwork("al", artlessID, model.ImageTypePrimary) + return err == nil && absent.Hash == "" + }) + ia, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + artfulHash = ia.Hash + Expect(artfulHash).ToNot(BeEmpty()) + }) + + It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) + Expect(albumCoverInList(artlessID)).To(BeEmpty()) + }) + + It("serves the found album's bytes with revalidation headers for a bare id", func() { + w := getCover("id", "al-"+artfulID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) + + It("serves the found album immutably when the exact hash is requested", func() { + w := getCover("id", "al-"+artfulID+"_"+artfulHash) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with an empty body when the client already holds the hash", func() { + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) + r.Header.Set("If-None-Match", `"`+artfulHash+`"`) + artRouter.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Bytes()).To(BeEmpty()) + }) + + It("serves the placeholder with no-store for an absent album", func() { + w := getCover("id", "al-"+artlessID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + + It("serves /share/img immutably for a JWT whose payload carries the hash", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 404 from /share/img for an absent entity", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artlessID}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) + +// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Service. +func buildArtworkRouter(art artwork.Service) *subsonic.Router { + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + return subsonic.New( + ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, + events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + ) +} + +// wipeScannedContent clears all scanned library content (including the golden fake library) so the +// next scan is a clean import. FKs are disabled for the bulk delete, mirroring harness.Restore. +func wipeScannedContent() { + GinkgoHelper() + _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { + _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) + Expect(err).ToNot(HaveOccurred(), "wiping %s", t) + } + _, err = db.Db().Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +func albumIDByName(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) + return albums[0].ID +} + +// writeArtworkTrack lays down one tag-distinct track (and optionally a folder cover) so the scanner +// creates a self-contained album; distinct ALBUM/ALBUMARTIST tags keep the two albums from merging. +func writeArtworkTrack(root, artist, album, title string, withCover bool) { + GinkgoHelper() + dir := filepath.Join(root, artist, album) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + mp3 := filepath.Join(dir, title+".mp3") + Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) + Expect(taglib.WriteTags(mp3, map[string][]string{ + "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, + }, taglib.Clear)).To(Succeed()) + if withCover { + Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) + } +} + +func readArtworkFixture(name string) []byte { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// newDummyImageCache backs the artwork.Service's resize cache. size=0 requests stream originals +// and never invoke the reader, so it only needs to satisfy the constructor; resize behavior is +// covered by the artwork package's own suites. +func newDummyImageCache(ctx context.Context) cache.FileCache { + GinkgoHelper() + c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in subsonic artwork e2e") + }) + Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + return c +} + +// runWorkerUntil drives the real worker loop until a condition holds, then cancels and joins it. +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, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index e652cf492..18ce76b4a 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -7,7 +7,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "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" @@ -53,8 +52,8 @@ var _ = Describe("Multi-Library Support", Ordered, func() { adminWithLibs = *loadedAdmin // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index 775fefe89..672cdf1d3 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -43,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { noopProvider{}, nil, // scanner events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index b986200ae..e367cdee5 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -95,13 +95,24 @@ func getArtistAlbumCount(a *model.Artist) int32 { } } +// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty +// string + the field's omitempty tag) when artwork is known absent. +func coverArtOrEmpty(id model.ArtworkID, absent bool) string { + if absent { + return "" + } + return id.String() +} + func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -114,12 +125,14 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), - UserRating: int32(a.Rating), + Id: a.ID, + Name: a.Name, + AlbumCount: getArtistAlbumCount(&a), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + UserRating: int32(a.Rating), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -208,7 +221,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = mf.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) child.ContentType = mf.ContentType() if ok && player.ReportRealPath { @@ -369,7 +382,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre - child.CoverArt = al.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID @@ -460,7 +473,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 3741462a2..eeb1ae544 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -320,6 +320,74 @@ var _ = Describe("helpers", func() { }) }) + Describe("artwork coverArt emission", func() { + const hash = "0123456789abcdef" + ctx := context.Background() + + DescribeTable("coverArtOrEmpty", + func(id model.ArtworkID, absent bool, expected string) { + Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) + }, + Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), + Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), + Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), + ) + + Describe("childFromAlbum", func() { + It("suffixes coverArt with the content hash when resolved", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + al := model.Album{ID: "al-1"} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("childFromMediaFile", func() { + It("suffixes coverArt with the content hash when resolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("toArtist / toArtistID3", func() { + r := httptest.NewRequest("GET", "/test", nil) + It("emits both coverArt and imageUrl when resolved", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) + Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) + }) + It("omits coverArt and imageUrl when known absent", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(BeEmpty()) + Expect(artist.ArtistImageUrl).To(BeEmpty()) + + id3 := toArtistID3(r, a) + Expect(id3.CoverArt).To(BeEmpty()) + Expect(id3.ArtistImageUrl).To(BeEmpty()) + }) + It("emits an imageUrl when unresolved", func() { + a := model.Artist{ID: "ar-1"} + Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) + }) + }) + }) + Describe("osChildFromMediaFile", func() { var mf model.MediaFile var ctx context.Context diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 089a1fdda..35e9c1945 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -10,9 +10,11 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -66,7 +68,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -78,11 +80,20 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } - defer imgReader.Close() - w.Header().Set("cache-control", "public, max-age=315360000") - w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) + // Access control: the serving path reads persisted state by id, bypassing the library and + // private-playlist filters. On this authenticated path, fall back to the placeholder for an + // entity the caller cannot see, so a guessed id can't leak artwork (matches the legacy load). + if id != "" && !img.Placeholder && !api.artworkAccessible(ctx, id) { + _ = img.Close() + img = artwork.PlaceholderFor(id) + } + defer img.Close() - cnt, err := io.Copy(w, imgReader) + artID, _ := model.ParseArtworkID(id) + if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { + return nil, nil + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } @@ -90,6 +101,37 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } +// artworkAccessible reports whether the caller may view the artwork for id, by resolving the +// underlying entity through the request-scoped (filtered) repositories. Radios are global and +// always accessible; an unparsable/unknown id defers to GetEntityByID. +func (api *Router) artworkAccessible(ctx context.Context, id string) bool { + artID, err := model.ParseArtworkID(id) + if err != nil { + _, err := model.GetEntityByID(ctx, api.ds, id) + return err == nil + } + var lookupErr error + switch artID.Kind { + case model.KindArtistArtwork: + _, lookupErr = api.ds.Artist(ctx).Get(artID.ID) + case model.KindAlbumArtwork: + _, lookupErr = api.ds.Album(ctx).Get(artID.ID) + case model.KindMediaFileArtwork: + _, lookupErr = api.ds.MediaFile(ctx).Get(artID.ID) + case model.KindPlaylistArtwork: + _, lookupErr = api.ds.Playlist(ctx).Get(artID.ID) + case model.KindDiscArtwork: + albumID, _, perr := model.ParseDiscArtworkID(artID.ID) + if perr != nil { + return false + } + _, lookupErr = api.ds.Album(ctx).Get(albumID) + default: // radio and anything else has no per-user artwork access control + return true + } + return lookupErr == nil +} + func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) artist, _ := p.String("artist") diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 9331dfbe4..1f6cbe84d 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -30,8 +30,11 @@ var _ = Describe("MediaRetrievalController", func() { var w *httptest.ResponseRecorder BeforeEach(func() { + albumRepo := &tests.MockAlbumRepo{} + albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, + MockedAlbum: albumRepo, } artwork = &fakeArtwork{data: "image data"} router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) @@ -60,6 +63,18 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(Equal(artwork.data)) }) + It("serves a placeholder for an entity the caller cannot access", func() { + // al-99 is not in the (filtered) album repo, so the caller must not get its bytes. + r := newGetRequest("id=al-99") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.String()).ToNot(Equal(artwork.data)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + }) + It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound r := newGetRequest("id=34", "size=128", "square=true") @@ -108,6 +123,53 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(BeEmpty()) }) }) + + Describe("caching headers", func() { + const hash = "0123456789abcdef" + + It("sets an ETag and no-cache for a bare id", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + + It("marks the response immutable when the id asserts the current hash", func() { + artwork.hash = hash + r := newGetRequest("id=al-34_" + hash) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with no body when If-None-Match matches", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + r.Header.Set("If-None-Match", `"`+hash+`"`) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(304)) + Expect(w.Body.Len()).To(BeZero()) + }) + + It("never caches a placeholder", func() { + artwork.placeholder = true + r := newGetRequest("id=al-missing") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + }) }) Describe("GetLyrics", func() { @@ -186,8 +248,11 @@ var _ = Describe("MediaRetrievalController", func() { }) type fakeArtwork struct { - artwork.Artwork + artwork.Service data string + hash string + lastUpdated time.Time + placeholder bool err error ctxCancelFunc func() recvId string @@ -195,18 +260,23 @@ type fakeArtwork struct { recvSquare bool } -func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (*artwork.Image, error) { if c.err != nil { - return nil, time.Time{}, c.err + return nil, c.err } c.recvId = id c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { c.ctxCancelFunc() - return nil, time.Time{}, context.Canceled + return nil, context.Canceled } - return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), + Hash: c.hash, + LastUpdated: c.lastUpdated, + Placeholder: c.placeholder, + }, nil } type mockedMediaFile struct { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 17ba1b2c9..bd53528d9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -151,7 +151,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Comment = p.Comment pls.Owner = p.OwnerName pls.Public = p.Public - pls.CoverArt = p.CoverArtID().String() + pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f0a2f8ac5..a7c9e2ec7 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -129,6 +129,19 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("artwork emission", func() { + It("suffixes coverArt with the content hash when resolved", func() { + playlist.ImageHash = "0123456789abcdef" + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) + }) + It("omits coverArt when known absent", func() { + playlist.ImageAbsent = true + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 4fbd6a53d..1fb266f1d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -74,10 +74,10 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { continue } - // Add coverArt if not legacy client + // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source var coverArt string if g.UploadedImage != "" { - coverArt = g.CoverArtID().String() + coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ CoverArt: coverArt, diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index e959ebe29..aa805abbf 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,10 +71,25 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) + + It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { + radioRepo.All = model.Radios{ + {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, + {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, + } + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) + Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) + }) }) Context("with a legacy client", func() { @@ -129,7 +144,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) }) }) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 5d4989ae5..cc787f041 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -110,11 +110,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { searchResult2 := &responses.SearchResult2{} searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: artist.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), + Id: artist.ID, + Name: artist.Name, + UserRating: int32(artist.Rating), + CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), + } + if !artist.ImageAbsent { + a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 4e72bd2e6..d31a50cfa 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -1,6 +1,8 @@ package subsonic import ( + "net/http" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -209,4 +211,32 @@ var _ = Describe("Search", func() { }) }) }) + + Describe("artwork emission", func() { + newSearchRequest := func() *http.Request { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) + return r.WithContext(ctx) + } + + It("omits coverArt and imageUrl for a known-absent artist", func() { + mockArtistRepo.SetData(model.Artists{ + {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, + }) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) + }) + + It("emits coverArt and imageUrl for an unresolved artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) + }) + }) }) diff --git a/tests/harness/harness.go b/tests/harness/harness.go index ff5ce8919..9e9e9c4ed 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" @@ -74,8 +73,8 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { u.Libraries = loaded.Libraries } - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index cc7d66b4d..17539255b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -76,6 +76,18 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, a := range all { + ids[i] = a.ID + } + return ids, nil +} + func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..4fdd198fe 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -113,6 +113,18 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, a := range all { + ids[i] = a.ID + } + return ids, nil +} + func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go new file mode 100644 index 000000000..cbb2f3f58 --- /dev/null +++ b/tests/mock_artwork_queue_repo.go @@ -0,0 +1,216 @@ +package tests + +import ( + "sort" + "sync" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkQueueRepo struct { + model.ArtworkQueueRepository + // mu guards Data so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex + Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) + Err error + // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + ItemArtworkSource *MockArtworkRepo + // ExistingIDs, keyed by item_kind, backs PurgeDangling; a nil per-kind map keeps that kind. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { + return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} +} + +func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + prev.RetryAt = now + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueItem + now := time.Now() + for _, it := range m.Data { + if !it.RetryAt.After(now) { + res = append(res, it) + } + } + sort.Slice(res, func(i, j int) bool { + if res[i].Priority != res[j].Priority { + return res[i].Priority > res[j].Priority + } + return res[i].EnqueuedAt.Before(res[j].EnqueuedAt) + }) + if len(res) > n { + res = res[:n] + } + return res, nil +} + +func (m *MockArtworkQueueRepo) MarkFailed(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + it, ok := m.Data[k] + if !ok { + return model.ErrNotFound + } + it.Attempts++ + it.RetryAt = retryAt + m.Data[k] = it + return nil +} + +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { + it.Attempts++ + it.RetryAt = retryAt + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) Delete(kind, id, imageType string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + delete(m.Data, iaKey(kind, id, imageType)) + return nil +} + +func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { + delete(m.Data, k) + } + return nil +} + +func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + existing := m.ExistingIDs[it.ItemKind] + if existing == nil { + continue + } + if !existing[it.ItemID] { + delete(m.Data, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkQueueRepo) Count() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockArtworkQueueRepo) EnqueueBump(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil || m.ItemArtworkSource == nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { + continue + } + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: model.ArtworkPriorityRecheck, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go new file mode 100644 index 000000000..aa873e7c6 --- /dev/null +++ b/tests/mock_artwork_repo.go @@ -0,0 +1,213 @@ +package tests + +import ( + "sync" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkRepo struct { + model.ArtworkRepository + // mu guards the maps so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex + Data map[string]model.Artwork + ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) + OrphanHashes []string + Err error + // ExistingIDs, keyed by item_kind, backs PurgeDanglingItemArtwork; nil map keeps everything. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkRepo() *MockArtworkRepo { + return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} +} + +func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } + +func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if a, ok := m.Data[hash]; ok { + return &a, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. + a.CreatedAt = time.Now() + m.Data[a.Hash] = *a + return nil +} + +func (m *MockArtworkRepo) GetImages(hashes []string) (map[string]model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.Artwork{} + for _, h := range hashes { + if a, ok := m.Data[h]; ok { + res[h] = a + } + } + return res, nil +} + +func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.OrphanHashes, m.Err +} + +func (m *MockArtworkRepo) GetAllMimes() (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + mimes := make(map[string]string, len(m.Data)) + for h, a := range m.Data { + mimes[h] = a.Mime + } + return mimes, nil +} + +func (m *MockArtworkRepo) PurgeDanglingItemArtwork() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, ia := range m.ItemData { + // A nil per-kind map means that kind isn't tracked by the test, so keep it. + existing := m.ExistingIDs[ia.ItemKind] + if existing == nil { + continue + } + if !existing[ia.ItemID] { + delete(m.ItemData, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + // Mirror the SQL re-check: only unreferenced rows older than the cutoff are deleted. + for _, h := range hashes { + if m.referenced(h) { + continue + } + if a, ok := m.Data[h]; ok && !a.CreatedAt.Before(createdBefore) { + continue + } + delete(m.Data, h) + } + return nil +} + +func (m *MockArtworkRepo) referenced(hash string) bool { + for _, ia := range m.ItemData { + if ia.Hash == hash { + return true + } + } + return false +} + +func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if ia, ok := m.ItemData[iaKey(kind, id, imageType)]; ok { + return &ia, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } + ia.UpdatedAt = time.Now() + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia + return nil +} + +func (m *MockArtworkRepo) DeleteForItem(kind, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + for k, ia := range m.ItemData { + if ia.ItemKind == kind && ia.ItemID == id { + delete(m.ItemData, k) + } + } + return nil +} + +func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + for k, ia := range m.ItemData { + if ia.ItemKind == kind && idSet[ia.ItemID] { + delete(m.ItemData, k) + } + } + return nil +} + +func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.ItemArtworkInfo{} + for _, id := range ids { + if ia, ok := m.ItemData[iaKey(kind, id, model.ImageTypePrimary)]; ok { + info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} + if a, ok := m.Data[ia.Hash]; ok { + info.BlurHash = a.BlurHash + } + res[id] = info + } + } + return res, nil +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index e016a28de..d8585fabf 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,8 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -247,6 +249,32 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { return db.MockedPlugin } +func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { + if db.MockedArtwork != nil { + return db.MockedArtwork + } + if db.RealDS != nil { + return db.RealDS.Artwork(ctx) + } + db.MockedArtwork = CreateMockArtworkRepo() + return db.MockedArtwork +} + +func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + if db.MockedArtworkQueue != nil { + return db.MockedArtworkQueue + } + if db.RealDS != nil { + return db.RealDS.ArtworkQueue(ctx) + } + q := CreateMockArtworkQueueRepo() + if aw, ok := db.Artwork(ctx).(*MockArtworkRepo); ok { + q.ItemArtworkSource = aw + } + db.MockedArtworkQueue = q + return db.MockedArtworkQueue +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 8f8842c8e..bb1fe3379 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -52,6 +52,18 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, p := range all { + ids[i] = p.ID + } + return ids, nil +} + func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 2835baadd..83751bb28 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -14,6 +14,7 @@ type MockPlaylistTrackRepo struct { Reordered bool AddCount int Err error + AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { @@ -66,6 +67,13 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model. }, nil } +func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return m.AlbumIDs, nil +} + func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index c50a529e5..6d1d45f6e 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -73,6 +73,18 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } +func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, r := range all { + ids[i] = r.ID + } + return ids, nil +} + func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error")