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.
This commit is contained in:
Deluan 2026-04-01 23:30:04 -04:00
parent 6cd689dfbf
commit d8abe80d2c
20 changed files with 42 additions and 42 deletions

View File

@ -88,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
@ -729,6 +730,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)

View File

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

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 {
@ -142,16 +141,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 := conf.Server.UICoverArtSize
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

@ -196,6 +196,7 @@ var staticData = sync.OnceValue(func() insights.Data {
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.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

@ -66,6 +66,7 @@ type Data struct {
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"` 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,7 @@ 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 +45,7 @@ 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 +60,7 @@ 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 +74,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 +87,7 @@ 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')