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
DefaultUIVolume int
UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
@ -729,6 +730,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)

View file

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

View file

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

View file

@ -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))
})
})
})

View file

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

View file

@ -66,6 +66,7 @@ type Data struct {
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"`

View file

@ -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)
}

View file

@ -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,

View file

@ -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"),

View file

@ -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 (

View file

@ -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 (

View file

@ -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 }) => {
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ import {
OverflowTooltip,
useImageLoadingState,
} from '../common'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import subsonic from '../subsonic'
const useStyles = makeStyles(
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
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 (

View file

@ -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 ? (
<CardMedia
component="img"
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}

View file

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

View file

@ -1,5 +1,5 @@
import { vi } from 'vitest'
import { COVER_ART_SIZE } from '../consts'
import config from '../config'
import subsonic from './index'
describe('getCoverArtUrl', () => {
@ -31,7 +31,7 @@ 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 +45,7 @@ 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 +60,7 @@ 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 +74,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 +87,7 @@ 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')