navidrome/server/serve_index_test.go
Deluan d8abe80d2c 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.
2026-04-02 16:40:39 -04:00

335 lines
12 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("serveIndex", func() {
var ds model.DataStore
mockUser := &mockedUserRepo{}
fs := os.DirFS("tests/fixtures")
BeforeEach(func() {
ds = &tests.MockDataStore{MockedUser: mockUser}
DeferCleanup(configtest.SetupConfig())
})
It("adds app_config to index.html", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
Expect(w.Code).To(Equal(200))
config := extractAppConfig(w.Body.String())
Expect(config).To(BeAssignableToTypeOf(map[string]any{}))
})
It("sets firstTime = true when User table is empty", func() {
mockUser.empty = true
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", true))
})
It("sets firstTime = false when User table is not empty", func() {
mockUser.empty = false
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", false))
})
DescribeTable("sets configuration values",
func(configSetter func(), configKey string, expectedValue any) {
configSetter()
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue(configKey, expectedValue))
},
Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"),
Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"),
Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)),
Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true),
Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true),
Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true),
Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true),
Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"),
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"),
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true),
Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true),
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true),
Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"),
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
)
DescribeTable("sets other UI configuration values",
func(configKey string, expectedValueFunc func() any) {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc()))
},
Entry("version", "version", func() any { return consts.Version }),
Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }),
Entry("losslessFormats", "losslessFormats", func() any {
return strings.ToUpper(strings.Join(mime.LosslessFormats, ","))
}),
Entry("separator", "separator", func() any { return string(os.PathSeparator) }),
)
Describe("loginBackgroundURL", func() {
Context("empty BaseURL", func() {
BeforeEach(func() {
conf.Server.BasePath = "/"
})
When("it is the default URL", func() {
It("points to the default URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURL))
})
})
When("it is the default offline URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
})
})
When("it is a custom URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
})
})
})
Context("with a BaseURL", func() {
BeforeEach(func() {
conf.Server.BasePath = "/music"
})
When("it is the default URL", func() {
It("points to the default URL with BaseURL prefix", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL))
})
})
When("it is the default offline URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
})
})
When("it is a custom URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
})
})
})
})
})
var _ = Describe("addShareData", func() {
var (
r *http.Request
data map[string]any
shareInfo *model.Share
)
BeforeEach(func() {
data = make(map[string]any)
r = httptest.NewRequest("GET", "/", nil)
})
Context("when shareInfo is nil or has an empty ID", func() {
It("should not modify data", func() {
addShareData(r, data, nil)
Expect(data).To(BeEmpty())
shareInfo = &model.Share{}
addShareData(r, data, shareInfo)
Expect(data).To(BeEmpty())
})
})
Context("when shareInfo is not nil and has a non-empty ID", func() {
BeforeEach(func() {
shareInfo = &model.Share{
ID: "testID",
Description: "Test description",
Downloadable: true,
Tracks: []model.MediaFile{
{
ID: "track1",
Title: "Track 1",
Artist: "Artist 1",
Album: "Album 1",
Duration: 100,
UpdatedAt: time.Date(2023, time.Month(3), 27, 0, 0, 0, 0, time.UTC),
},
{
ID: "track2",
Title: "Track 2",
Artist: "Artist 2",
Album: "Album 2",
Duration: 200,
UpdatedAt: time.Date(2023, time.Month(3), 26, 0, 0, 0, 0, time.UTC),
},
},
Contents: "Test contents",
URL: "https://example.com/share/testID",
ImageURL: "https://example.com/share/testID/image",
}
})
It("should populate data with shareInfo data", func() {
addShareData(r, data, shareInfo)
Expect(data["ShareDescription"]).To(Equal(shareInfo.Description))
Expect(data["ShareURL"]).To(Equal(shareInfo.URL))
Expect(data["ShareImageURL"]).To(Equal(shareInfo.ImageURL))
var shareData shareData
err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &shareData)
Expect(err).NotTo(HaveOccurred())
Expect(shareData.ID).To(Equal(shareInfo.ID))
Expect(shareData.Description).To(Equal(shareInfo.Description))
Expect(shareData.Downloadable).To(Equal(shareInfo.Downloadable))
Expect(shareData.Tracks).To(HaveLen(len(shareInfo.Tracks)))
for i, track := range shareData.Tracks {
Expect(track.ID).To(Equal(shareInfo.Tracks[i].ID))
Expect(track.Title).To(Equal(shareInfo.Tracks[i].Title))
Expect(track.Artist).To(Equal(shareInfo.Tracks[i].Artist))
Expect(track.Album).To(Equal(shareInfo.Tracks[i].Album))
Expect(track.Duration).To(Equal(shareInfo.Tracks[i].Duration))
Expect(track.UpdatedAt).To(Equal(shareInfo.Tracks[i].UpdatedAt))
}
})
Context("when shareInfo has an empty description", func() {
BeforeEach(func() {
shareInfo.Description = ""
})
It("should use shareInfo.Contents as ShareDescription", func() {
addShareData(r, data, shareInfo)
Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents))
})
})
})
})
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`)
func extractAppConfig(body string) map[string]any {
config := make(map[string]any)
match := appConfigRegex.FindStringSubmatch(body)
if match == nil {
return config
}
str, err := strconv.Unquote(match[1])
if err != nil {
panic(fmt.Sprintf("%s: %s", match[1], err))
}
if err := json.Unmarshal([]byte(str), &config); err != nil {
panic(err)
}
return config
}
type mockedUserRepo struct {
model.UserRepository
empty bool
}
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
if u.empty {
return 0, nil
}
return 1, nil
}