diff --git a/Makefile b/Makefile index 3bad5b620..0673838c2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') 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 export CGO_CFLAGS_ALLOW=--define-prefix diff --git a/conf/configuration.go b/conf/configuration.go index 653e3d8e1..58239884a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -70,6 +70,7 @@ type configOptions struct { MPVCmdTemplate string CoverArtPriority string CoverArtQuality int + EnableWebPEncoding bool ArtistArtPriority string ArtistImageFolder string DiscArtPriority string @@ -87,6 +88,7 @@ type configOptions struct { DefaultLanguage string DefaultUIVolume int UISearchDebounceMs int + UICoverArtSize int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -141,7 +143,6 @@ type configOptions struct { DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool - DevJpegCoverArt bool } type scannerOptions struct { @@ -424,6 +425,13 @@ func Load(noConfigDump bool) { // Removed options 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 for _, hook := range hooks { hook() @@ -712,10 +720,13 @@ func setViperDefaults() { viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") + viper.SetDefault("mpvpath", "") 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("coverartquality", 75) + viper.SetDefault("enablewebpencoding", false) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) @@ -726,6 +737,7 @@ func setViperDefaults() { viper.SetDefault("defaultlanguage", "") viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) + viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) @@ -794,6 +806,7 @@ func setViperDefaults() { viper.SetDefault("plugins.enabled", true) viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.autoreload", false) + viper.SetDefault("plugins.loglevel", "") // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) @@ -807,7 +820,7 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) 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("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -823,7 +836,6 @@ func setViperDefaults() { viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) - viper.SetDefault("devjpegcoverart", false) } func init() { diff --git a/consts/consts.go b/consts/consts.go index f1010a872..ff5dedc2b 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -85,11 +85,9 @@ const ( ) const ( - UICoverArtSize = 600 + DefaultUICoverArtSize = 300 ) -var CacheWarmerImageSizes = []int{UICoverArtSize} - // Prometheus options const ( PrometheusDefaultPath = "/metrics" diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 4b2359898..380352d3f 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -380,24 +380,24 @@ var _ = Describe("Artwork", 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" 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(format).To(Equal("png")) 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() { + 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("webp")) + 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)) @@ -430,24 +430,51 @@ var _ = Describe("Artwork", func() { Expect(img.Bounds().Size().X).To(Equal(size)) Expect(img.Bounds().Size().Y).To(Equal(size)) }, - Entry("portrait png image", "png", "webp", false, 200), - Entry("landscape png image", "png", "webp", true, 200), - Entry("portrait jpg image", "jpg", "webp", false, 200), - Entry("landscape jpg image", "jpg", "webp", true, 200), + 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("DevJpegCoverArt is true and square is false", func() { + When("EnableWebPEncoding is true and square is false", 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" 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("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().Y).To(Equal(15)) }) @@ -463,11 +490,11 @@ var _ = Describe("Artwork", func() { 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 BeforeEach(func() { - conf.Server.DevJpegCoverArt = true + conf.Server.EnableWebPEncoding = false }) It("returns PNG for square mode", func() { dirName := createImage("png", false, 200) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index bd1359b74..5090d638e 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -10,7 +10,6 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "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 // 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 { // If image cache is disabled, return a NOOP implementation if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { @@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), + 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 @@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan 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) { @@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - for _, size := range consts.CacheWarmerImageSizes { - 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 + 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) } - return nil + _, err = io.Copy(io.Discard, r) + r.Close() + return err } func NoopCacheWarmer() CacheWarmer { diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 9798ea8d6..a5da2004c 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -12,7 +12,6 @@ import ( "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/utils/cache" . "github.com/onsi/ginkgo/v2" @@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() { Eventually(func() []int { return aw.getCachedSizes() - }).Should(ContainElements(consts.UICoverArtSize)) + }).Should(ContainElements(conf.Server.UICoverArtSize)) }) }) }) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 6de1d31d1..641b12b33 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar func (a *albumArtworkReader) Key() string { hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hashInput += conf.Server.Agents + hashInput = conf.Server.Agents + hashInput } hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 72baad434..85a19a4c3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -19,6 +19,16 @@ import ( 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{ New: func() any { return new(bytes.Buffer) @@ -98,28 +108,26 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return nil, 0, fmt.Errorf("reading image data: %w", err) } - // Preserve animation for animated images (skip for square thumbnails) - if !a.square { - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + // Preserve animation for animated images + if isAnimatedGIF(data) { + if a.a.ffmpeg.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil } return resizeStaticImage(data, a.size, a.square) } 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 { return nil, 0, err } @@ -159,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro buf := bufPool.Get().(*bytes.Buffer) buf.Reset() - if conf.Server.DevJpegCoverArt { - if square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - } else { + if conf.Server.EnableWebPEncoding { err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) } if err != nil { bufPool.Put(buf) diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go index 7c9e21c0a..7c14f5e44 100644 --- a/core/artwork/reader_resized_test.go +++ b/core/artwork/reader_resized_test.go @@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() { Expect(len(output)).To(BeNumerically(">", 0)) }) - It("skips animation for square thumbnails even with animated GIF", func() { + 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)) - // Should fall through to static resize (not ffmpeg conversion) - // The minimal test GIF may or may not resize successfully, - // but ffmpeg should NOT have been called for animated conversion - _ = result - _ = err - // Verify by checking the mock wasn't used for animated conversion: - // If ffmpeg was called, it would return mock data, not static resize result + 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)) }) }) @@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated WebP for square thumbnails", func() { + It("preserves animated WebP for square thumbnails", func() { r.square = true data := createAnimatedWebPBytes() - // Should fall through to static resize, which will fail on fake WebP data - _, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static decode will fail on our minimal test WebP bytes (not a real image) - Expect(err).To(HaveOccurred()) + 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)) }) }) @@ -104,15 +108,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated PNG for square thumbnails", func() { + It("preserves animated PNG for square thumbnails", func() { r.square = true data := createAPNGBytes() - // Should fall through to static resize result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG decode should succeed on our APNG (it's a valid PNG) - if err == nil { - Expect(result).ToNot(BeNil()) - } + 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)) }) }) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index b87f1df5e..f069d3fb6 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -195,6 +195,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload 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.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index b316c866d..34648a49b 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -65,6 +65,8 @@ type Data struct { EnablePrometheus bool `json:"enablePrometheus,omitempty"` EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` diff --git a/go.mod b/go.mod index 3c6f7dc69..fcee08c7e 100644 --- a/go.mod +++ b/go.mod @@ -36,12 +36,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.37 + github.com/mattn/go-sqlite3 v1.14.38 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 - github.com/pelletier/go-toml/v2 v2.2.4 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect diff --git a/go.sum b/go.sum index eb6e6bf27..e0671367a 100644 --- a/go.sum +++ b/go.sum @@ -167,8 +167,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= -github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= -github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= +github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -199,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b9d91f087..bc3c7a3d3 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{ "releasedate": {field: "media_file.release_date"}, "size": {field: "media_file.size"}, "compilation": {field: "media_file.compilation"}, + "missing": {field: "media_file.missing"}, "explicitstatus": {field: "media_file.explicit_status"}, "dateadded": {field: "media_file.created_at"}, "datemodified": {field: "media_file.updated_at"}, @@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{ "catalognumber": {field: "media_file.catalog_num"}, "filepath": {field: "media_file.path"}, "filetype": {field: "media_file.suffix"}, + "codec": {field: "media_file.codec"}, "duration": {field: "media_file.duration"}, "bitrate": {field: "media_file.bit_rate"}, "bitdepth": {field: "media_file.bit_depth"}, + "samplerate": {field: "media_file.sample_rate"}, "bpm": {field: "media_file.bpm"}, "channels": {field: "media_file.channels"}, "loved": {field: "COALESCE(annotation.starred, false)"}, diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json index 7a13c471d..60eaa6d7c 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -36,7 +36,9 @@ "bitDepth": "Bitprofundo", "sampleRate": "Elprena rapido", "missing": "Mankaj", - "libraryName": "Biblioteko" + "libraryName": "Biblioteko", + "composer": "", + "disc": "" }, "actions": { "addToQueue": "Ludi Poste", @@ -46,7 +48,8 @@ "download": "Elŝuti", "playNext": "Ludu Poste", "info": "Akiri Informon", - "showInPlaylist": "Montri en Ludlisto" + "showInPlaylist": "Montri en Ludlisto", + "instantMix": "" } }, "album": { @@ -328,6 +331,82 @@ "scanInProgress": "Skano progresas...", "noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto" } + }, + "plugin": { + "name": "", + "fields": { + "id": "", + "name": "", + "description": "", + "version": "Versio", + "author": "Aŭtoro", + "website": "Retejo", + "permissions": "Permesoj", + "enabled": "Ebligite", + "status": "", + "path": "Vojo", + "lastError": "Eraro", + "hasError": "Eraro", + "updatedAt": "Ĝisdatigite", + "createdAt": "", + "configKey": "Ŝlosilo", + "configValue": "", + "allUsers": "", + "selectedUsers": "", + "allLibraries": "", + "selectedLibraries": "", + "allowWriteAccess": "" + }, + "sections": { + "status": "", + "info": "", + "configuration": "", + "manifest": "", + "usersPermission": "", + "libraryPermission": "" + }, + "status": { + "enabled": "", + "disabled": "" + }, + "actions": { + "enable": "", + "disable": "", + "disabledDueToError": "", + "disabledUsersRequired": "", + "disabledLibrariesRequired": "", + "addConfig": "", + "rescan": "" + }, + "notifications": { + "enabled": "", + "disabled": "", + "updated": "", + "error": "" + }, + "validation": { + "invalidJson": "" + }, + "messages": { + "configHelp": "", + "clickPermissions": "", + "noConfig": "", + "allUsersHelp": "", + "noUsers": "", + "permissionReason": "", + "usersRequired": "", + "allLibrariesHelp": "", + "noLibraries": "", + "librariesRequired": "", + "requiredHosts": "", + "configValidationError": "", + "schemaRenderError": "", + "allowWriteAccessHelp": "" + }, + "placeholders": { + "configKey": "", + "configValue": "" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn", "remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.", "noSimilarSongsFound": "Neniuj similaj kantoj trovitaj", - "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj" + "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj", + "startingInstantMix": "", + "uploadCover": "", + "removeCover": "", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" }, "menu": { "library": "Biblioteko", @@ -597,7 +683,8 @@ "exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato", "exportFailed": "Malsukcesis kopii agordojn", "devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)", - "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj" + "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj", + "downloadToml": "" } }, "activity": { diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 58954c9dc..6bfd09d0e 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -23,6 +23,7 @@ "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", "channels": "Kanalak", + "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", "starred": "Gogokoa", "comment": "Iruzkina", @@ -355,7 +356,8 @@ "allUsers": "Baimendu erabiltzaile guztiak", "selectedUsers": "Hautatutako erabiltzaileak", "allLibraries": "Baimendu liburutegi guztiak", - "selectedLibraries": "Hautatutako liburutegiak" + "selectedLibraries": "Hautatutako liburutegiak", + "allowWriteAccess": "Eman idazteko baimena" }, "sections": { "status": "Egoera", @@ -400,6 +402,7 @@ "allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.", "noLibraries": "Ez da liburutegirik hautatu", "librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.", + "allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.", "requiredHosts": "Beharrezko ostatatzaileak" }, "placeholders": { @@ -554,6 +557,12 @@ } }, "message": { + "uploadCover": "Igo azala", + "removeCover": "Kendu azala", + "coverUploaded": "Diskoaren azala eguneratu da", + "coverRemoved": "Diskoaren azala kendu da", + "coverUploadError": "Errorea diskoaren azala igotzean", + "coverRemoveError": "Errorea diskoaren azala kentzean", "note": "OHARRA", "transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.", "transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.", @@ -673,6 +682,7 @@ "currentValue": "Uneko balioa", "configurationFile": "Konfigurazio-fitxategia", "exportToml": "Esportatu konfigurazioa (TOML)", + "downloadToml": "Deskargatu konfigurazioa (TOML)", "exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan", "exportFailed": "Konfigurazioa kopiatzeak huts egin du", "devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 86793ee19..3f638c13c 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -37,7 +37,8 @@ "sampleRate": "Sample waarde", "missing": "Ontbrekend", "libraryName": "Bibliotheek", - "composer": "" + "composer": "Componist", + "disc": "Schijf %{discNumber}" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -48,7 +49,7 @@ "playNext": "Volgende", "info": "Meer info", "showInPlaylist": "Toon in afspeellijst", - "instantMix": "" + "instantMix": "Instant mix" } }, "album": { @@ -350,10 +351,11 @@ "createdAt": "Geinstalleerd", "configKey": "Sleutel", "configValue": "Waarde", - "allUsers": "Alle gebruikers toelaten", + "allUsers": "Sta toe voor alle gebruikers", "selectedUsers": "Geselecteerde gebruikers", - "allLibraries": "Alle bibliotheken toestaan", - "selectedLibraries": "Geselecteerde bibliotheken" + "allLibraries": "Sta toe voor alle bibliotheken", + "selectedLibraries": "Geselecteerde bibliotheken", + "allowWriteAccess": "Sta schrijftoegang toe" }, "sections": { "status": "Status", @@ -379,26 +381,27 @@ "notifications": { "enabled": "Plugin actief", "disabled": "Plugin niet actief", - "updated": "Plugin geupdate", + "updated": "Plugin bijgewerkt", "error": "Fout bij updaten plugin" }, "validation": { "invalidJson": "Configuratie moet geldige JSON zijn" }, "messages": { - "configHelp": "", + "configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.", "clickPermissions": "Klik op permissie voor details", "noConfig": "Geen configuratie ingesteld", - "allUsersHelp": "", + "allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.", "noUsers": "Geen gebruikers geselecteerd", "permissionReason": "Reden", - "usersRequired": "", - "allLibrariesHelp": "", + "usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.", + "allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.", "noLibraries": "Geen bibliotheken geselecteerd", - "librariesRequired": "", + "librariesRequired": "Deze plug-in heeft toegang nodig tot bibliotheek informatie. Selecteer welke bibliotheken de plug-in toegang to heeft, of schakel 'sta toe voor alle bibliotheken' in.", "requiredHosts": "Benodigde hosts", - "configValidationError": "", - "schemaRenderError": "" + "configValidationError": "Configuratiecheck mislukt", + "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." }, "placeholders": { "configKey": "Sleutel", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden", "noTopSongsFound": "Geen beste nummers gevonden", - "startingInstantMix": "" + "startingInstantMix": "Laden van Instant mix...", + "uploadCover": "Albumhoes toevoegen", + "removeCover": "Verwijder albumhoes", + "coverUploaded": "Albumhoes bijgewerkt", + "coverRemoved": "Albumhoes verwijderd", + "coverUploadError": "Fout bij het toevoegen albumhoes", + "coverRemoveError": "Fout bij verwijderen albumhoes" }, "menu": { "library": "Bibliotheek", @@ -674,7 +683,8 @@ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat", "exportFailed": "Kopiëren van configuratie mislukt", "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)", - "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd" + "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd", + "downloadToml": "Download configuratie (TOML)" } }, "activity": { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 15e63d4db..24ecff1d6 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -6,6 +6,7 @@ import ( "net/http" "path" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "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 { 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 { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } diff --git a/server/serve_index.go b/server/serve_index.go index 0d1a2f330..bd5be44f5 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -55,6 +55,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, + "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e08a42643..7515e7276 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() { Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), 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("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index baae7514b..a8c3da68c 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -159,6 +159,10 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response } func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist { + player, ok := request.PlayerFrom(ctx) + if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) { + return nil + } pls := responses.OpenSubsonicPlaylist{} if p.IsSmartPlaylist() { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 41701b4de..3f2a2068e 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -128,6 +128,23 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) + Context("when no player in context", func() { It("returns all fields", func() { result := router.buildPlaylist(ctx, playlist) @@ -213,6 +230,23 @@ var _ = Describe("buildPlaylist", func() { Expect(result.ValidUntil).To(Equal(&validUntil)) }) }) + + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) }) }) diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 7121566f9..4fbd6a53d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -75,8 +75,12 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro continue } // Add coverArt if not legacy client + var coverArt string + if g.UploadedImage != "" { + coverArt = g.CoverArtID().String() + } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ - CoverArt: g.UploadedImage, + CoverArt: coverArt, } } diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index d5b764f60..e959ebe29 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,7 +71,7 @@ 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("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) @@ -129,7 +129,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("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) }) }) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 2411b8611..cec66eb8b 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -18,7 +18,7 @@ import { useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { @@ -32,7 +32,6 @@ import { useAlbumsPerPage, useImageLoadingState, } from '../common' -import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -255,7 +254,7 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index c8a161571..9717618fa 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -20,7 +20,8 @@ import { OverflowTooltip, useImageUrl, } from '../common' -import { COVER_ART_SIZE, DraggableTypes } from '../consts' +import config from '../config' +import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({ [record], ) - const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const { imgUrl, loading: imageLoading } = useImageUrl(url) return ( diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index bc2312477..dda761097 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -15,7 +15,6 @@ import { import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' -import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { { { handleCloseLightbox, } = 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) return ( diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index 5f804535a..bbe001e6f 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' 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({ coverParent: { @@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => { {record.uploadedImage ? ( { @@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => { 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('size=600') @@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => { 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('size=600') @@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => { 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('size=600') @@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => { 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('size=600') @@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => { 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('size=600')