navidrome/core/metrics/insights.go
Deluan Quintão c87db92cee
fix(artwork): address WebP performance regression on low-power hardware (#5286)
* refactor(artwork): rename DevJpegCoverArt to EnableWebPEncoding

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

* feat: add configurable UICoverArtSize option

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

* style: fix prettier formatting in subsonic test

* feat: log WebP encoder/decoder selection

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

* fix(artwork): address PR review feedback

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

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

This reverts commit 3a213ef03e401930977138afe0e84c83290df683.

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

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

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

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

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

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

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

* fix: default EnableWebPEncoding to false and reduce artwork parallelism

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

* fix(configuration): update DefaultUICoverArtSize to 300

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

* fix(Makefile): append EXTRA_BUILD_TAGS to GO_BUILD_TAGS

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-04 15:17:01 -04:00

339 lines
10 KiB
Go

package metrics
import (
"bytes"
"context"
"encoding/json"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/singleton"
)
type Insights interface {
Run(ctx context.Context)
LastRun(ctx context.Context) (timestamp time.Time, success bool)
}
var (
insightsID string
)
type insightsCollector struct {
ds model.DataStore
lastRun atomic.Int64
lastStatus atomic.Bool
}
func GetInstance(ds model.DataStore) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
if err != nil {
log.Trace("Could not get Insights ID from DB. Creating one", err)
id = uuid.NewString()
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
if err != nil {
log.Trace("Could not save Insights ID to DB", err)
}
}
insightsID = id
return &insightsCollector{ds: ds}
})
}
func (c *insightsCollector) Run(ctx context.Context) {
for {
// Refresh admin context on each iteration to handle cases where
// admin user wasn't available on previous runs
insightsCtx := auth.WithAdminUser(ctx, c.ds)
u, _ := request.UserFrom(insightsCtx)
if !u.IsAdmin {
log.Trace(insightsCtx, "No admin user available, skipping insights collection")
} else {
c.sendInsights(insightsCtx)
}
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
case <-ctx.Done():
return
}
}
}
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
t := c.lastRun.Load()
return time.UnixMilli(t), c.lastStatus.Load()
}
func (c *insightsCollector) sendInsights(ctx context.Context) {
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
if err != nil {
log.Trace(ctx, "Could not check user count", err)
return
}
if count == 0 {
log.Trace(ctx, "No users found, skipping Insights data collection")
return
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
data := c.collect(ctx)
if data == nil {
return
}
body := bytes.NewReader(data)
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
if err != nil {
log.Trace(ctx, "Could not create Insights request", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
}
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
c.lastRun.Store(time.Now().UnixMilli())
c.lastStatus.Store(resp.StatusCode < 300)
resp.Body.Close()
}
func buildInfo() (map[string]string, string) {
bInfo := map[string]string{}
var version string
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Value == "" {
continue
}
bInfo[setting.Key] = setting.Value
}
version = info.GoVersion
}
return bInfo, version
}
func getFSInfo(path string) *insights.FSInfo {
var info insights.FSInfo
// Normalize the path
absPath, err := filepath.Abs(path)
if err != nil {
return nil
}
absPath = filepath.Clean(absPath)
fsType, err := getFilesystemType(absPath)
if err != nil {
return nil
}
info.Type = fsType
return &info
}
var staticData = sync.OnceValue(func() insights.Data {
// Basic info
data := insights.Data{
InsightsID: insightsID,
Version: consts.Version,
}
// Build info
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
}
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
data.OS.NumCPU = runtime.NumCPU()
data.OS.Version, data.OS.Distro = getOSVersion()
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder)
if conf.Server.CacheFolder != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
}
if conf.Server.Backup.Path != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
}
// Config info
data.Config.LogLevel = conf.Server.LogLevel
data.Config.LogFileConfigured = conf.Server.LogFile != ""
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
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
data.Config.EnableSharing = conf.Server.EnableSharing
data.Config.EnableStarRating = conf.Server.EnableStarRating
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.Search.FullString
data.Config.SearchBackend = conf.Server.Search.Backend
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data
})
func (c *insightsCollector) collect(ctx context.Context) []byte {
data := staticData()
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
// Library info
var err error
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading tracks count", err)
}
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading albums count", err)
}
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading artists count", err)
}
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading playlists count", err)
}
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading shares count", err)
}
data.Library.Radios, err = c.ds.Radio(ctx).Count()
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading libraries count", err)
}
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
if err != nil {
log.Trace(ctx, "Error reading file suffixes count", err)
}
// Check for smart playlists
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
if err != nil {
log.Trace(ctx, "Error checking for smart playlists", err)
}
// Collect plugins if permitted and enabled
if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled {
data.Plugins = c.collectPlugins(ctx)
}
// Collect active players if permitted
if conf.Server.DevEnablePlayerInsights {
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active players count", err)
}
}
// Memory info
var m runtime.MemStats
runtime.ReadMemStats(&m)
data.Mem.Alloc = m.Alloc
data.Mem.TotalAlloc = m.TotalAlloc
data.Mem.Sys = m.Sys
data.Mem.NumGC = m.NumGC
// Marshal to JSON
resp, err := json.Marshal(data)
if err != nil {
log.Trace(ctx, "Could not marshal Insights data", err)
return nil
}
return resp
}
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
})
return count > 0, err
}
// collectPlugins collects information about installed plugins
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
// TODO Fix import/inject cycles
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
info := manager.GetPluginInfo()
result := make(map[string]insights.PluginInfo, len(info))
for name, p := range info {
result[name] = insights.PluginInfo{
Name: p.Name,
Version: p.Version,
}
}
return result
}