fix(artwork): address WebP performance regression on low-power hardware (#5286)

* refactor(artwork): rename DevJpegCoverArt to EnableWebPEncoding

Replaced the internal DevJpegCoverArt flag with a user-facing
EnableWebPEncoding config option (defaults to true). When disabled, the
fallback encoding now preserves the original image format — PNG sources
stay PNG for non-square resizes, matching v0.60.3 behavior. The previous
implementation incorrectly re-encoded PNG sources as JPEG in non-square
mode. Also added EnableWebPEncoding to the insights data.

* feat: add configurable UICoverArtSize option

Converted the hardcoded UICoverArtSize constant (600px) into a
configurable option, allowing users to reduce the cover art size
requested by the UI to mitigate slow image encoding. The value is
served to the frontend via the app config and used by all components
that request cover art. Also simplified the cache warmer by removing
a single-iteration loop in favor of direct code.

* style: fix prettier formatting in subsonic test

* feat: log WebP encoder/decoder selection

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): address PR review feedback

- Add DevJpegCoverArt to logRemovedOptions so users with the old config
  key get a clear warning instead of a silent ignore.
- Include EnableWebPEncoding in the resized artwork cache key to prevent
  stale WebP responses after toggling the setting.
- Skip animated GIF to WebP conversion via ffmpeg when EnableWebPEncoding
  is false, so the setting is consistent across all image types.
- Fix data race in cache warmer by reading UICoverArtSize at construction
  time instead of per-image, avoiding concurrent access with config
  cleanup in tests.
- Clarify cache warmer docstring to accurately describe caching behavior.

* Revert "fix(artwork): address PR review feedback"

This reverts commit 3a213ef03e401930977138afe0e84c83290df683.

* fix(artwork): avoid data race in cache warmer config access

Capture UICoverArtSize at construction time instead of reading from
conf.Server on each doCacheImage call. The background goroutine could
race with test config cleanup, causing intermittent race detector
failures in CI.

* fix(configuration): clamp UICoverArtSize to be within 200 and 1200

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): preserve album cache key compatibility with v0.60.3

Restored the v0.60.3 hash input order for album artwork cache keys
(Agents + CoverArtPriority) so that existing caches remain valid on
upgrade when EnableExternalServices is true. Also ensures
CoverArtPriority is always part of the hash even when external services
are disabled, fixing a v0.60.3 bug where changing CoverArtPriority had
no effect on cache invalidation.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: default EnableWebPEncoding to false and reduce artwork parallelism

Changed EnableWebPEncoding default to false so that upgrading users get
the same JPEG/PNG encoding behavior as v0.60.3 out of the box, avoiding
the WebP WASM overhead until native libwebp is available. Users can
opt in to WebP by setting EnableWebPEncoding=true. Also reduced the
default DevArtworkMaxRequests to half the CPU count (min 2) to lower
resource pressure during artwork processing.

* fix(configuration): update DefaultUICoverArtSize to 300

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(Makefile): append EXTRA_BUILD_TAGS to GO_BUILD_TAGS

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-04-04 15:17:01 -04:00 committed by GitHub
parent 80c1e60259
commit c87db92cee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 142 additions and 78 deletions

View File

@ -1,6 +1,8 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc) NODE_VERSION=$(shell cat .nvmrc)
GO_BUILD_TAGS=netgo,sqlite_fts5
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets # Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix export CGO_CFLAGS_ALLOW=--define-prefix

View File

@ -70,6 +70,7 @@ type configOptions struct {
MPVCmdTemplate string MPVCmdTemplate string
CoverArtPriority string CoverArtPriority string
CoverArtQuality int CoverArtQuality int
EnableWebPEncoding bool
ArtistArtPriority string ArtistArtPriority string
ArtistImageFolder string ArtistImageFolder string
DiscArtPriority string DiscArtPriority string
@ -87,6 +88,7 @@ type configOptions struct {
DefaultLanguage string DefaultLanguage string
DefaultUIVolume int DefaultUIVolume int
UISearchDebounceMs int UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool EnableReplayGain bool
EnableCoverAnimation bool EnableCoverAnimation bool
EnableNowPlaying bool EnableNowPlaying bool
@ -141,7 +143,6 @@ type configOptions struct {
DevOptimizeDB bool DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool DevEnableMediaFileProbe bool
DevJpegCoverArt bool
} }
type scannerOptions struct { type scannerOptions struct {
@ -424,6 +425,13 @@ func Load(noConfigDump bool) {
// Removed options // Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret") logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
Server.UICoverArtSize = newValue
}
// Call init hooks // Call init hooks
for _, hook := range hooks { for _, hook := range hooks {
hook() hook()
@ -716,6 +724,7 @@ func setViperDefaults() {
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverartquality", 75) viper.SetDefault("coverartquality", 75)
viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "") viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
@ -728,6 +737,7 @@ func setViperDefaults() {
viper.SetDefault("defaultlanguage", "") viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true) viper.SetDefault("enablenowplaying", true)
@ -810,7 +820,7 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true) viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true) viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000) viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU())) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
@ -826,7 +836,6 @@ func setViperDefaults() {
viper.SetDefault("devoptimizedb", true) viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true) viper.SetDefault("devenablemediafileprobe", true)
viper.SetDefault("devjpegcoverart", false)
} }
func init() { func init() {

View File

@ -85,11 +85,9 @@ const (
) )
const ( const (
UICoverArtSize = 600 DefaultUICoverArtSize = 300
) )
var CacheWarmerImageSizes = []int{UICoverArtSize}
// Prometheus options // Prometheus options
const ( const (
PrometheusDefaultPath = "/metrics" PrometheusDefaultPath = "/metrics"

View File

@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
}) })
}) })
When("Square is false", func() { When("Square is false", func() {
It("returns WebP even if original image is a PNG", func() { It("returns PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png" conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r) img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("webp")) Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15))
}) })
It("returns WebP if original image is not a PNG", func() { It("returns JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg" conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r) img, format, err := image.Decode(r)
Expect(format).To(Equal("webp")) Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200)) Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200)) Expect(img.Bounds().Size().Y).To(Equal(200))
@ -430,24 +430,51 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().X).To(Equal(size)) Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size)) Expect(img.Bounds().Size().Y).To(Equal(size))
}, },
Entry("portrait png image", "png", "webp", false, 200), Entry("portrait png image", "png", "png", false, 200),
Entry("landscape png image", "png", "webp", true, 200), Entry("landscape png image", "png", "png", true, 200),
Entry("portrait jpg image", "jpg", "webp", false, 200), Entry("portrait jpg image", "jpg", "png", false, 200),
Entry("landscape jpg image", "jpg", "webp", true, 200), Entry("landscape jpg image", "jpg", "png", true, 200),
) )
}) })
When("DevJpegCoverArt is true and square is false", func() { When("EnableWebPEncoding is true and square is false", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Server.DevJpegCoverArt = true conf.Server.EnableWebPEncoding = true
}) })
It("returns JPEG even if original image is a PNG", func() { It("returns WebP even if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png" conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r) img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("jpeg")) 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().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15))
}) })
@ -463,11 +490,11 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().Y).To(Equal(200)) Expect(img.Bounds().Size().Y).To(Equal(200))
}) })
}) })
When("DevJpegCoverArt is true and square is true", func() { When("EnableWebPEncoding is false and square is true", func() {
var alCover model.Album var alCover model.Album
BeforeEach(func() { BeforeEach(func() {
conf.Server.DevJpegCoverArt = true conf.Server.EnableWebPEncoding = false
}) })
It("returns PNG for square mode", func() { It("returns PNG for square mode", func() {
dirName := createImage("png", false, 200) dirName := createImage("png", false, 200)

View File

@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@ -24,7 +23,7 @@ type CacheWarmer interface {
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background // 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 // 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 in the UICoverArtSize constant. // image size, as well as the size defined by the UICoverArtSize config option.
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
// If image cache is disabled, return a NOOP implementation // If image cache is disabled, return a NOOP implementation
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
} }
a := &cacheWarmer{ a := &cacheWarmer{
artwork: artwork, artwork: artwork,
cache: cache, cache: cache,
buffer: make(map[model.ArtworkID]struct{}), buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1), 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 // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
} }
type cacheWarmer struct { type cacheWarmer struct {
artwork Artwork artwork Artwork
buffer map[model.ArtworkID]struct{} buffer map[model.ArtworkID]struct{}
mutex sync.Mutex mutex sync.Mutex
cache cache.FileCache cache cache.FileCache
wakeSignal chan struct{} wakeSignal chan struct{}
coverArtSize int
} }
func (a *cacheWarmer) PreCache(artID model.ArtworkID) { func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
for _, size := range consts.CacheWarmerImageSizes { size := a.coverArtSize
r, _, err := a.artwork.Get(ctx, id, size, true) r, _, err := a.artwork.Get(ctx, id, size, true)
if err != nil { if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
}
_, err = io.Copy(io.Discard, r)
r.Close()
return err
} }
return nil _, err = io.Copy(io.Discard, r)
r.Close()
return err
} }
func NoopCacheWarmer() CacheWarmer { func NoopCacheWarmer() CacheWarmer {

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
Eventually(func() []int { Eventually(func() []int {
return aw.getCachedSizes() return aw.getCachedSizes()
}).Should(ContainElements(consts.UICoverArtSize)) }).Should(ContainElements(conf.Server.UICoverArtSize))
}) })
}) })
}) })

View File

@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
func (a *albumArtworkReader) Key() string { func (a *albumArtworkReader) Key() string {
hashInput := conf.Server.CoverArtPriority hashInput := conf.Server.CoverArtPriority
if conf.Server.EnableExternalServices { if conf.Server.EnableExternalServices {
hashInput += conf.Server.Agents hashInput = conf.Server.Agents + hashInput
} }
hash := md5.Sum([]byte(hashInput)) hash := md5.Sum([]byte(hashInput))
return fmt.Sprintf( return fmt.Sprintf(

View File

@ -19,6 +19,16 @@ import (
xdraw "golang.org/x/image/draw" xdraw "golang.org/x/image/draw"
) )
func init() {
conf.AddHook(func() {
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
} else {
log.Debug("Using native libwebp for WebP encoding/decoding")
}
})
}
var bufPool = sync.Pool{ var bufPool = sync.Pool{
New: func() any { New: func() any {
return new(bytes.Buffer) return new(bytes.Buffer)
@ -117,7 +127,7 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
} }
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, _, err := image.Decode(bytes.NewReader(data)) original, format, err := image.Decode(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -157,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
buf := bufPool.Get().(*bytes.Buffer) buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() buf.Reset()
if conf.Server.DevJpegCoverArt { if conf.Server.EnableWebPEncoding {
if square {
err = png.Encode(buf, dst)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
}
} else {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
} else if format == "png" || square {
err = png.Encode(buf, dst)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
} }
if err != nil { if err != nil {
bufPool.Put(buf) bufPool.Put(buf)

View File

@ -195,6 +195,8 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
data.Config.CoverArtQuality = conf.Server.CoverArtQuality data.Config.CoverArtQuality = conf.Server.CoverArtQuality
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
data.Config.EnableDownloads = conf.Server.EnableDownloads data.Config.EnableDownloads = conf.Server.EnableDownloads

View File

@ -65,6 +65,8 @@ type Data struct {
EnablePrometheus bool `json:"enablePrometheus,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"`
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
CoverArtQuality int `json:"coverArtQuality,omitempty"` CoverArtQuality int `json:"coverArtQuality,omitempty"`
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"`

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"path" "path"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/core/publicurl"
@ -81,7 +82,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
s.URL = ShareURL(r, s.ID) s.URL = ShareURL(r, s.ID)
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize)
for i := range s.Tracks { for i := range s.Tracks {
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
} }

View File

@ -55,6 +55,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"defaultLanguage": conf.Server.DefaultLanguage, "defaultLanguage": conf.Server.DefaultLanguage,
"defaultUIVolume": conf.Server.DefaultUIVolume, "defaultUIVolume": conf.Server.DefaultUIVolume,
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
"uiCoverArtSize": conf.Server.UICoverArtSize,
"enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableCoverAnimation": conf.Server.EnableCoverAnimation,
"enableNowPlaying": conf.Server.EnableNowPlaying, "enableNowPlaying": conf.Server.EnableNowPlaying,
"gaTrackingId": conf.Server.GATrackingID, "gaTrackingId": conf.Server.GATrackingID,

View File

@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() {
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),

View File

@ -18,7 +18,7 @@ import {
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts' import config from '../config'
import 'react-image-lightbox/style.css' import 'react-image-lightbox/style.css'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { import {
@ -32,7 +32,6 @@ import {
useAlbumsPerPage, useAlbumsPerPage,
useImageLoadingState, useImageLoadingState,
} from '../common' } from '../common'
import config from '../config'
import { formatFullDate, intersperse } from '../utils' import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks' import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
@ -255,7 +254,7 @@ const AlbumDetails = (props) => {
}) })
}, [record]) }, [record])
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE) const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
return ( return (

View File

@ -20,7 +20,8 @@ import {
OverflowTooltip, OverflowTooltip,
useImageUrl, useImageUrl,
} from '../common' } from '../common'
import { COVER_ART_SIZE, DraggableTypes } from '../consts' import config from '../config'
import { DraggableTypes } from '../consts'
import clsx from 'clsx' import clsx from 'clsx'
import { AlbumDatesField } from './AlbumDatesField.jsx' import { AlbumDatesField } from './AlbumDatesField.jsx'
@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({
[record], [record],
) )
const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const { imgUrl, loading: imageLoading } = useImageUrl(url) const { imgUrl, loading: imageLoading } = useImageUrl(url)
return ( return (

View File

@ -15,7 +15,6 @@ import {
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo' import AlbumInfo from '../album/AlbumInfo'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<CardMedia <CardMedia
key={record.id} key={record.id}
component="img" component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad} onLoad={handleImageLoad}

View File

@ -11,7 +11,6 @@ import {
useImageLoadingState, useImageLoadingState,
} from '../common' } from '../common'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<CardMedia <CardMedia
key={record.id} key={record.id}
component="img" component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad} onLoad={handleImageLoad}

View File

@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin'
import { Avatar } from '@material-ui/core' import { Avatar } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx' import clsx from 'clsx'
import { COVER_ART_SIZE } from '../consts' import config from '../config'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl' import { useImageUrl } from './useImageUrl'
@ -28,7 +28,7 @@ export const CoverArtAvatar = ({
const record = recordProp || recordContext const record = recordProp || recordContext
const square = variant !== 'circular' const square = variant !== 'circular'
const url = record const url = record
? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
: null : null
const { imgUrl } = useImageUrl(url) const { imgUrl } = useImageUrl(url)
if (!record) return null if (!record) return null

View File

@ -21,6 +21,7 @@ const defaultConfig = {
defaultLanguage: '', defaultLanguage: '',
defaultUIVolume: 100, defaultUIVolume: 100,
uiSearchDebounceMs: 200, uiSearchDebounceMs: 200,
uiCoverArtSize: 600,
enableUserEditing: true, enableUserEditing: true,
enableArtworkUpload: true, enableArtworkUpload: true,
enableSharing: true, enableSharing: true,

View File

@ -26,8 +26,6 @@ DraggableTypes.ALL.push(
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
export const COVER_ART_SIZE = 600
export const DEFAULT_SHARE_BITRATE = 128 export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [ export const BITRATE_CHOICES = [

View File

@ -18,7 +18,7 @@ import {
OverflowTooltip, OverflowTooltip,
useImageLoadingState, useImageLoadingState,
} from '../common' } from '../common'
import { COVER_ART_SIZE } from '../consts' import config from '../config'
import subsonic from '../subsonic' import subsonic from '../subsonic'
const useStyles = makeStyles( const useStyles = makeStyles(
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
handleCloseLightbox, handleCloseLightbox,
} = useImageLoadingState(record.id) } = useImageLoadingState(record.id)
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
return ( return (

View File

@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations' import { urlValidate } from '../utils/validations'
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
const useStyles = makeStyles({ const useStyles = makeStyles({
coverParent: { coverParent: {
@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => {
{record.uploadedImage ? ( {record.uploadedImage ? (
<CardMedia <CardMedia
component="img" component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)} src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={handleImageError} onError={handleImageError}

View File

@ -1,5 +1,6 @@
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
export async function songFromRadio(radio) { export async function songFromRadio(radio) {
if (!radio) { if (!radio) {
@ -8,7 +9,7 @@ export async function songFromRadio(radio) {
let cover = RADIO_PLACEHOLDER_IMAGE let cover = RADIO_PLACEHOLDER_IMAGE
if (radio.uploadedImage) { if (radio.uploadedImage) {
cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true) cover = subsonic.getCoverArtUrl(radio, config.uiCoverArtSize, true)
} else { } else {
// Try favicon as fallback // Try favicon as fallback
try { try {

View File

@ -1,5 +1,5 @@
import { vi } from 'vitest' import { vi } from 'vitest'
import { COVER_ART_SIZE } from '../consts' import config from '../config'
import subsonic from './index' import subsonic from './index'
describe('getCoverArtUrl', () => { describe('getCoverArtUrl', () => {
@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(
playlistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('pl-playlist-123') expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=600') expect(url).toContain('size=600')
@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => {
sync: true, sync: true,
} }
const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(
playlistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('pl-playlist-123') expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=600') expect(url).toContain('size=600')
@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(
albumRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('al-album-123') expect(url).toContain('al-album-123')
expect(url).toContain('size=600') expect(url).toContain('size=600')
@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true)
expect(url).toContain('mf-song-123') expect(url).toContain('mf-song-123')
expect(url).toContain('size=600') expect(url).toContain('size=600')
@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true) const url = subsonic.getCoverArtUrl(
artistRecord,
config.uiCoverArtSize,
true,
)
expect(url).toContain('ar-artist-123') expect(url).toContain('ar-artist-123')
expect(url).toContain('size=600') expect(url).toContain('size=600')