mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
* 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 3a213ef03e.
* 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>
892 lines
31 KiB
Go
892 lines
31 KiB
Go
package conf
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/go-viper/encoding/ini"
|
|
"github.com/kr/pretty"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/scheduler"
|
|
"github.com/navidrome/navidrome/utils/run"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type configOptions struct {
|
|
ConfigFile string
|
|
Address string
|
|
Port int
|
|
UnixSocketPerm string
|
|
MusicFolder string
|
|
DataFolder string
|
|
CacheFolder string
|
|
DbPath string
|
|
LogLevel string
|
|
LogFile string
|
|
SessionTimeout time.Duration
|
|
BaseURL string
|
|
BasePath string
|
|
BaseHost string
|
|
BaseScheme string
|
|
TLSCert string
|
|
TLSKey string
|
|
UILoginBackgroundURL string
|
|
UIWelcomeMessage string
|
|
MaxSidebarPlaylists int
|
|
EnableTranscodingConfig bool
|
|
EnableTranscodingCancellation bool
|
|
EnableDownloads bool
|
|
EnableExternalServices bool
|
|
EnableM3UExternalAlbumArt bool
|
|
EnableInsightsCollector bool
|
|
EnableMediaFileCoverArt bool
|
|
TranscodingCacheSize string
|
|
ImageCacheSize string
|
|
AlbumPlayCountMode string
|
|
EnableArtworkPrecache bool
|
|
AutoImportPlaylists bool
|
|
DefaultPlaylistPublicVisibility bool
|
|
PlaylistsPath string
|
|
SmartPlaylistRefreshDelay time.Duration
|
|
AutoTranscodeDownload bool
|
|
DefaultDownsamplingFormat string
|
|
Search searchOptions `json:",omitzero"`
|
|
SimilarSongsMatchThreshold int
|
|
RecentlyAddedByModTime bool
|
|
PreferSortTags bool
|
|
IgnoredArticles string
|
|
IndexGroups string
|
|
FFmpegPath string
|
|
MPVPath string
|
|
MPVCmdTemplate string
|
|
CoverArtPriority string
|
|
CoverArtQuality int
|
|
EnableWebPEncoding bool
|
|
ArtistArtPriority string
|
|
ArtistImageFolder string
|
|
DiscArtPriority string
|
|
LyricsPriority string
|
|
EnableGravatar bool
|
|
EnableFavourites bool
|
|
EnableStarRating bool
|
|
EnableUserEditing bool
|
|
EnableArtworkUpload bool
|
|
EnableSharing bool
|
|
ShareURL string
|
|
DefaultShareExpiration time.Duration
|
|
DefaultDownloadableShare bool
|
|
DefaultTheme string
|
|
DefaultLanguage string
|
|
DefaultUIVolume int
|
|
UISearchDebounceMs int
|
|
UICoverArtSize int
|
|
EnableReplayGain bool
|
|
EnableCoverAnimation bool
|
|
EnableNowPlaying bool
|
|
GATrackingID string
|
|
EnableLogRedacting bool
|
|
AuthRequestLimit int
|
|
AuthWindowLength time.Duration
|
|
PasswordEncryptionKey string
|
|
ExtAuth extAuthOptions
|
|
Plugins pluginsOptions
|
|
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
|
Prometheus prometheusOptions `json:",omitzero"`
|
|
Scanner scannerOptions `json:",omitzero"`
|
|
Jukebox jukeboxOptions `json:",omitzero"`
|
|
Backup backupOptions `json:",omitzero"`
|
|
PID pidOptions `json:",omitzero"`
|
|
Inspect inspectOptions `json:",omitzero"`
|
|
Subsonic subsonicOptions `json:",omitzero"`
|
|
LastFM lastfmOptions `json:",omitzero"`
|
|
Deezer deezerOptions `json:",omitzero"`
|
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
|
EnableScrobbleHistory bool
|
|
Tags map[string]TagConf `json:",omitempty"`
|
|
Agents string
|
|
|
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
|
DevLogLevels map[string]string `json:",omitempty"`
|
|
DevLogSourceLine bool
|
|
DevEnableProfiler bool
|
|
DevAutoCreateAdminPassword string
|
|
DevAutoLoginUsername string
|
|
DevActivityPanel bool
|
|
DevActivityPanelUpdateRate time.Duration
|
|
DevSidebarPlaylists bool
|
|
DevShowArtistPage bool
|
|
DevUIShowConfig bool
|
|
DevNewEventStream bool
|
|
DevOffsetOptimize int
|
|
DevArtworkMaxRequests int
|
|
DevArtworkThrottleBacklogLimit int
|
|
DevArtworkThrottleBacklogTimeout time.Duration
|
|
DevArtistInfoTimeToLive time.Duration
|
|
DevAlbumInfoTimeToLive time.Duration
|
|
DevExternalScanner bool
|
|
DevScannerThreads uint
|
|
DevSelectiveWatcher bool
|
|
DevInsightsInitialDelay time.Duration
|
|
DevEnablePlayerInsights bool
|
|
DevEnablePluginsInsights bool
|
|
DevPluginCompilationTimeout time.Duration
|
|
DevExternalArtistFetchMultiplier float64
|
|
DevOptimizeDB bool
|
|
DevPreserveUnicodeInExternalCalls bool
|
|
DevEnableMediaFileProbe bool
|
|
}
|
|
|
|
type scannerOptions struct {
|
|
Enabled bool
|
|
Schedule string
|
|
WatcherWait time.Duration
|
|
ScanOnStartup bool
|
|
Extractor string
|
|
ArtistJoiner string
|
|
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
|
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
|
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
|
PurgeMissing string // Values: "never", "always", "full"
|
|
}
|
|
|
|
type subsonicOptions struct {
|
|
AppendSubtitle bool
|
|
AppendAlbumVersion bool
|
|
ArtistParticipations bool
|
|
DefaultReportRealPath bool
|
|
EnableAverageRating bool
|
|
LegacyClients string
|
|
MinimalClients string
|
|
}
|
|
|
|
type TagConf struct {
|
|
Ignore bool `yaml:"ignore" json:",omitempty"`
|
|
Aliases []string `yaml:"aliases" json:",omitempty"`
|
|
Type string `yaml:"type" json:",omitempty"`
|
|
MaxLength int `yaml:"maxLength" json:",omitempty"`
|
|
Split []string `yaml:"split" json:",omitempty"`
|
|
Album bool `yaml:"album" json:",omitempty"`
|
|
}
|
|
|
|
type lastfmOptions struct {
|
|
Enabled bool
|
|
ApiKey string //nolint:gosec
|
|
Secret string //nolint:gosec
|
|
Language string
|
|
ScrobbleFirstArtistOnly bool
|
|
|
|
// Computed values
|
|
Languages []string // Computed from Language, split by comma
|
|
}
|
|
|
|
type deezerOptions struct {
|
|
Enabled bool
|
|
Language string
|
|
|
|
// Computed values
|
|
Languages []string // Computed from Language, split by comma
|
|
}
|
|
|
|
type listenBrainzOptions struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
ArtistAlgorithm string
|
|
TrackAlgorithm string
|
|
}
|
|
|
|
type httpHeaderOptions struct {
|
|
FrameOptions string
|
|
}
|
|
|
|
type prometheusOptions struct {
|
|
Enabled bool
|
|
MetricsPath string
|
|
Password string //nolint:gosec
|
|
}
|
|
|
|
type AudioDeviceDefinition []string
|
|
|
|
type jukeboxOptions struct {
|
|
Enabled bool
|
|
Devices []AudioDeviceDefinition
|
|
Default string
|
|
AdminOnly bool
|
|
}
|
|
|
|
type backupOptions struct {
|
|
Count int
|
|
Path string
|
|
Schedule string
|
|
}
|
|
|
|
type pidOptions struct {
|
|
Track string
|
|
Album string
|
|
}
|
|
|
|
type inspectOptions struct {
|
|
Enabled bool
|
|
MaxRequests int
|
|
BacklogLimit int
|
|
BacklogTimeout int
|
|
}
|
|
|
|
type pluginsOptions struct {
|
|
Enabled bool
|
|
Folder string
|
|
CacheSize string
|
|
AutoReload bool
|
|
LogLevel string
|
|
}
|
|
|
|
type extAuthOptions struct {
|
|
TrustedSources string
|
|
UserHeader string
|
|
LogoutURL string
|
|
}
|
|
|
|
type searchOptions struct {
|
|
Backend string
|
|
FullString bool
|
|
}
|
|
|
|
// logFatal prints a fatal error message to stderr and exits.
|
|
// Overridden in tests to allow testing fatal paths.
|
|
var logFatal = func(args ...any) {
|
|
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var (
|
|
Server = &configOptions{}
|
|
hooks []func()
|
|
)
|
|
|
|
func LoadFromFile(confFile string) {
|
|
viper.SetConfigFile(confFile)
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
logFatal("Error reading config file:", err)
|
|
}
|
|
Load(true)
|
|
}
|
|
|
|
func Load(noConfigDump bool) {
|
|
parseIniFileConfiguration()
|
|
remapEnvVarKeysFromConfig()
|
|
|
|
// Map deprecated options to their new names for backwards compatibility
|
|
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
|
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
|
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
|
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
|
|
|
err := viper.Unmarshal(&Server)
|
|
if err != nil {
|
|
logFatal("Error parsing config:", err)
|
|
}
|
|
|
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
|
if err != nil {
|
|
logFatal("Error creating data path:", err)
|
|
}
|
|
|
|
if Server.CacheFolder == "" {
|
|
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
|
}
|
|
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
|
if err != nil {
|
|
logFatal("Error creating cache path:", err)
|
|
}
|
|
|
|
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
|
if err != nil {
|
|
logFatal("Error creating artwork path:", err)
|
|
}
|
|
|
|
if Server.Plugins.Enabled {
|
|
if Server.Plugins.Folder == "" {
|
|
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
|
}
|
|
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
|
if err != nil {
|
|
logFatal("Error creating plugins path:", err)
|
|
}
|
|
}
|
|
|
|
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
|
if Server.DbPath == "" {
|
|
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
|
}
|
|
|
|
if Server.Backup.Path != "" {
|
|
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
|
if err != nil {
|
|
logFatal("Error creating backup path:", err)
|
|
}
|
|
}
|
|
|
|
out := os.Stderr
|
|
if Server.LogFile != "" {
|
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
|
}
|
|
log.SetOutput(out)
|
|
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
|
// When running under systemd, prepend syslog priority prefixes so
|
|
// journald assigns the correct severity to each log line.
|
|
// Note that we have an additional environment variable, as JOURNAL_STREAM
|
|
// can be present in a systemd environment even if not running as a systemd service
|
|
log.EnableJournalFormat()
|
|
}
|
|
|
|
log.SetLevelString(Server.LogLevel)
|
|
log.SetLogLevels(Server.DevLogLevels)
|
|
log.SetLogSourceLine(Server.DevLogSourceLine)
|
|
log.SetRedacting(Server.EnableLogRedacting)
|
|
|
|
err = run.Sequentially(
|
|
validateScanSchedule,
|
|
validateBackupSchedule,
|
|
validatePlaylistsPath,
|
|
validatePurgeMissingOption,
|
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
|
)
|
|
if err != nil {
|
|
os.Exit(1)
|
|
}
|
|
|
|
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
|
|
|
if Server.BaseURL != "" {
|
|
u, err := url.Parse(Server.BaseURL)
|
|
if err != nil {
|
|
logFatal("Invalid BaseURL:", err)
|
|
}
|
|
Server.BasePath = u.Path
|
|
u.Path = ""
|
|
u.RawQuery = ""
|
|
Server.BaseHost = u.Host
|
|
Server.BaseScheme = u.Scheme
|
|
}
|
|
|
|
// Log configuration source
|
|
if Server.ConfigFile != "" {
|
|
log.Info("Loaded configuration", "file", Server.ConfigFile)
|
|
} else if hasNDEnvVars() {
|
|
log.Info("No configuration file found. Loaded configuration only from environment variables")
|
|
} else {
|
|
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
|
|
}
|
|
|
|
// Print current configuration if log level is Debug
|
|
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
|
|
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
|
|
if Server.EnableLogRedacting {
|
|
prettyConf = log.Redact(prettyConf)
|
|
}
|
|
_, _ = fmt.Fprintln(out, prettyConf)
|
|
}
|
|
|
|
if !Server.EnableExternalServices {
|
|
disableExternalServices()
|
|
}
|
|
|
|
// Make sure we don't have empty PIDs
|
|
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
|
|
Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
|
|
|
|
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
|
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
|
|
|
|
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
|
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
|
|
|
// Deprecated options
|
|
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
|
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
|
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
|
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
|
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
|
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
|
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
|
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
func logDeprecatedOptions(oldName, newName string) {
|
|
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
|
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
|
logWarning := func(oldName, newName string) {
|
|
if newName != "" {
|
|
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
|
} else {
|
|
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
|
|
}
|
|
}
|
|
if os.Getenv(envVar) != "" {
|
|
logWarning(envVar, newEnvVar)
|
|
}
|
|
if viper.InConfig(oldName) {
|
|
logWarning(oldName, newName)
|
|
}
|
|
}
|
|
|
|
// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
|
|
// not available anymore
|
|
func logRemovedOptions(options ...string) {
|
|
for _, option := range options {
|
|
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
|
logWarning := func(option string) {
|
|
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
|
}
|
|
if viper.InConfig(option) {
|
|
logWarning(option)
|
|
}
|
|
if os.Getenv(envVar) != "" {
|
|
logWarning(envVar)
|
|
}
|
|
}
|
|
}
|
|
|
|
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
|
|
// using environment variable names) and remaps them to canonical Viper keys with a warning.
|
|
func remapEnvVarKeysFromConfig() {
|
|
for _, key := range viper.AllKeys() {
|
|
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
|
|
continue
|
|
}
|
|
stripped := strings.TrimPrefix(key, "nd_")
|
|
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
|
|
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
|
displayCanonical := toPascalCase(canonicalKey)
|
|
|
|
if viper.InConfig(canonicalKey) {
|
|
logFatal(fmt.Sprintf(
|
|
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
|
|
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
|
|
displayNDKey, displayCanonical,
|
|
))
|
|
return
|
|
}
|
|
|
|
viper.Set(canonicalKey, viper.Get(key))
|
|
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
|
"The 'ND_' prefix is only needed for environment variables.\n",
|
|
displayNDKey, displayCanonical,
|
|
)
|
|
}
|
|
}
|
|
|
|
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
|
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
|
func mapDeprecatedOption(legacyName, newName string) {
|
|
if viper.IsSet(legacyName) {
|
|
viper.Set(newName, viper.Get(legacyName))
|
|
}
|
|
}
|
|
|
|
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
|
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
|
// section into the root level.
|
|
func parseIniFileConfiguration() {
|
|
cfgFile := viper.ConfigFileUsed()
|
|
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
|
var iniConfig map[string]any
|
|
err := viper.Unmarshal(&iniConfig)
|
|
if err != nil {
|
|
logFatal("Error parsing config:", err)
|
|
}
|
|
cfg, ok := iniConfig["default"].(map[string]any)
|
|
if !ok {
|
|
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
|
}
|
|
err = viper.MergeConfigMap(cfg)
|
|
if err != nil {
|
|
logFatal("Error parsing config:", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func disableExternalServices() {
|
|
log.Info("All external integrations are DISABLED!")
|
|
Server.EnableInsightsCollector = false
|
|
Server.EnableM3UExternalAlbumArt = false
|
|
Server.LastFM.Enabled = false
|
|
Server.Deezer.Enabled = false
|
|
Server.ListenBrainz.Enabled = false
|
|
Server.Agents = ""
|
|
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
|
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
}
|
|
}
|
|
|
|
func validatePlaylistsPath() error {
|
|
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
_, err := doublestar.Match(path, "")
|
|
if err != nil {
|
|
log.Error("Invalid PlaylistsPath", "path", path, err)
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseLanguages parses a comma-separated language string into a slice.
|
|
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
|
func parseLanguages(lang string) []string {
|
|
var languages []string
|
|
for l := range strings.SplitSeq(lang, ",") {
|
|
l = strings.TrimSpace(l)
|
|
if l != "" {
|
|
languages = append(languages, l)
|
|
}
|
|
}
|
|
if len(languages) == 0 {
|
|
return []string{consts.DefaultInfoLanguage}
|
|
}
|
|
return languages
|
|
}
|
|
|
|
func validatePurgeMissingOption() error {
|
|
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
|
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
|
if !valid {
|
|
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
|
log.Error(err.Error())
|
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScanSchedule() error {
|
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
|
Server.Scanner.Schedule = ""
|
|
return nil
|
|
}
|
|
var err error
|
|
Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule")
|
|
return err
|
|
}
|
|
|
|
func validateBackupSchedule() error {
|
|
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
|
Server.Backup.Schedule = ""
|
|
return nil
|
|
}
|
|
var err error
|
|
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule")
|
|
return err
|
|
}
|
|
|
|
func validateSchedule(schedule, field string) (string, error) {
|
|
_, err := scheduler.ParseCrontab(schedule)
|
|
if err != nil {
|
|
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
|
}
|
|
return schedule, err
|
|
}
|
|
|
|
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
|
// It returns a function that can be used as a hook to validate URLs in the config.
|
|
func validateURL(optionName, optionURL string) func() error {
|
|
return func() error {
|
|
if optionURL == "" {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(optionURL)
|
|
if err != nil {
|
|
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
|
|
return err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
|
log.Error(err.Error())
|
|
return err
|
|
}
|
|
// Require an absolute URL with a non-empty host and no opaque component.
|
|
if u.Host == "" || u.Opaque != "" {
|
|
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
|
log.Error(err.Error())
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func normalizeSearchBackend(value string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
switch v {
|
|
case "fts", "legacy":
|
|
return v
|
|
default:
|
|
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
|
|
return "fts"
|
|
}
|
|
}
|
|
|
|
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
|
|
// Example: "scanner.schedule" → "Scanner.Schedule"
|
|
func toPascalCase(key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(key, ".")
|
|
for i, part := range parts {
|
|
if len(part) > 0 {
|
|
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
|
}
|
|
}
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
|
func AddHook(hook func()) {
|
|
hooks = append(hooks, hook)
|
|
}
|
|
|
|
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
|
|
func hasNDEnvVars() bool {
|
|
for _, env := range os.Environ() {
|
|
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setViperDefaults() {
|
|
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
|
viper.SetDefault("cachefolder", "")
|
|
viper.SetDefault("datafolder", ".")
|
|
viper.SetDefault("loglevel", "info")
|
|
viper.SetDefault("logfile", "")
|
|
viper.SetDefault("address", "0.0.0.0")
|
|
viper.SetDefault("port", 4533)
|
|
viper.SetDefault("unixsocketperm", "0660")
|
|
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
|
viper.SetDefault("baseurl", "")
|
|
viper.SetDefault("tlscert", "")
|
|
viper.SetDefault("tlskey", "")
|
|
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
|
|
viper.SetDefault("uiwelcomemessage", "")
|
|
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
|
viper.SetDefault("enabletranscodingconfig", false)
|
|
viper.SetDefault("enabletranscodingcancellation", false)
|
|
viper.SetDefault("transcodingcachesize", "100MB")
|
|
viper.SetDefault("imagecachesize", "100MB")
|
|
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
|
viper.SetDefault("enableartworkprecache", true)
|
|
viper.SetDefault("autoimportplaylists", true)
|
|
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
|
viper.SetDefault("playlistspath", "")
|
|
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
|
viper.SetDefault("enabledownloads", true)
|
|
viper.SetDefault("enableexternalservices", true)
|
|
viper.SetDefault("enablem3uexternalalbumart", false)
|
|
viper.SetDefault("enablemediafilecoverart", true)
|
|
viper.SetDefault("autotranscodedownload", false)
|
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
|
viper.SetDefault("search.fullstring", false)
|
|
viper.SetDefault("search.backend", "fts")
|
|
viper.SetDefault("similarsongsmatchthreshold", 85)
|
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
|
viper.SetDefault("prefersorttags", false)
|
|
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)
|
|
viper.SetDefault("enablefavourites", true)
|
|
viper.SetDefault("enablestarrating", true)
|
|
viper.SetDefault("enableuserediting", true)
|
|
viper.SetDefault("defaulttheme", "Dark")
|
|
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)
|
|
viper.SetDefault("enableartworkupload", true)
|
|
viper.SetDefault("enablesharing", false)
|
|
viper.SetDefault("shareurl", "")
|
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
|
viper.SetDefault("defaultdownloadableshare", false)
|
|
viper.SetDefault("gatrackingid", "")
|
|
viper.SetDefault("enableinsightscollector", true)
|
|
viper.SetDefault("enablelogredacting", true)
|
|
viper.SetDefault("authrequestlimit", 5)
|
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
|
viper.SetDefault("passwordencryptionkey", "")
|
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
|
viper.SetDefault("extauth.trustedsources", "")
|
|
viper.SetDefault("extauth.logouturl", "")
|
|
viper.SetDefault("prometheus.enabled", false)
|
|
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
|
viper.SetDefault("prometheus.password", "")
|
|
viper.SetDefault("jukebox.enabled", false)
|
|
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
|
viper.SetDefault("jukebox.default", "")
|
|
viper.SetDefault("jukebox.adminonly", true)
|
|
viper.SetDefault("scanner.enabled", true)
|
|
viper.SetDefault("scanner.schedule", "0")
|
|
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
|
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
|
viper.SetDefault("scanner.scanonstartup", true)
|
|
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
|
viper.SetDefault("scanner.genreseparators", "")
|
|
viper.SetDefault("scanner.groupalbumreleases", false)
|
|
viper.SetDefault("scanner.followsymlinks", true)
|
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
|
viper.SetDefault("subsonic.artistparticipations", false)
|
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
|
viper.SetDefault("subsonic.legacyclients", "DSub")
|
|
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
|
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
|
viper.SetDefault("lastfm.enabled", true)
|
|
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
|
viper.SetDefault("lastfm.apikey", "")
|
|
viper.SetDefault("lastfm.secret", "")
|
|
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
|
viper.SetDefault("deezer.enabled", true)
|
|
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
|
viper.SetDefault("listenbrainz.enabled", true)
|
|
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
|
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
|
viper.SetDefault("enablescrobblehistory", true)
|
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
|
viper.SetDefault("backup.path", "")
|
|
viper.SetDefault("backup.schedule", "")
|
|
viper.SetDefault("backup.count", 0)
|
|
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
|
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
|
viper.SetDefault("inspect.enabled", true)
|
|
viper.SetDefault("inspect.maxrequests", 1)
|
|
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
|
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
|
viper.SetDefault("plugins.folder", "")
|
|
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)
|
|
viper.SetDefault("devenableprofiler", false)
|
|
viper.SetDefault("devautocreateadminpassword", "")
|
|
viper.SetDefault("devautologinusername", "")
|
|
viper.SetDefault("devactivitypanel", true)
|
|
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
|
viper.SetDefault("devsidebarplaylists", true)
|
|
viper.SetDefault("devshowartistpage", true)
|
|
viper.SetDefault("devuishowconfig", true)
|
|
viper.SetDefault("devneweventstream", true)
|
|
viper.SetDefault("devoffsetoptimize", 50000)
|
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
|
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
|
viper.SetDefault("devexternalscanner", true)
|
|
viper.SetDefault("devscannerthreads", 5)
|
|
viper.SetDefault("devselectivewatcher", true)
|
|
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
|
|
viper.SetDefault("devenableplayerinsights", true)
|
|
viper.SetDefault("devenablepluginsinsights", true)
|
|
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
|
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
|
viper.SetDefault("devoptimizedb", true)
|
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
|
viper.SetDefault("devenablemediafileprobe", true)
|
|
}
|
|
|
|
func init() {
|
|
setViperDefaults()
|
|
}
|
|
|
|
func InitConfig(cfgFile string, loadEnvVars bool) {
|
|
codecRegistry := viper.NewCodecRegistry()
|
|
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
|
|
LoadOptions: ini.LoadOptions{
|
|
UnescapeValueDoubleQuotes: true,
|
|
UnescapeValueCommentSymbols: true,
|
|
},
|
|
})
|
|
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
|
|
|
|
cfgFile = getConfigFile(cfgFile)
|
|
if cfgFile != "" {
|
|
// Use config file from the flag.
|
|
viper.SetConfigFile(cfgFile)
|
|
} else {
|
|
// Search config in local directory with name "navidrome" (without extension).
|
|
viper.AddConfigPath(".")
|
|
viper.SetConfigName("navidrome")
|
|
}
|
|
|
|
_ = viper.BindEnv("port")
|
|
if loadEnvVars {
|
|
viper.SetEnvPrefix("ND")
|
|
replacer := strings.NewReplacer(".", "_")
|
|
viper.SetEnvKeyReplacer(replacer)
|
|
viper.AutomaticEnv()
|
|
}
|
|
|
|
err := viper.ReadInConfig()
|
|
if viper.ConfigFileUsed() != "" && err != nil {
|
|
logFatal("Navidrome could not open config file:", err)
|
|
}
|
|
}
|
|
|
|
// getConfigFile returns the path to the config file, either from the flag or from the environment variable.
|
|
// If it is defined in the environment variable, it will check if the file exists.
|
|
func getConfigFile(cfgFile string) string {
|
|
if cfgFile != "" {
|
|
return cfgFile
|
|
}
|
|
cfgFile = os.Getenv("ND_CONFIGFILE")
|
|
if cfgFile != "" {
|
|
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
|
|
return cfgFile
|
|
}
|
|
}
|
|
return ""
|
|
}
|