mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
* feat(api): add OpenAPI v1 spec skeleton, lint ruleset and bundle tooling vacuum v0.30.6's `bundle --composed` mangles component names for this spec's multi-file layout (duplicates Problem as Problem__schemas etc.), so api-bundle uses the Redocly CLI (npx @redocly/cli bundle) instead. * fix(api): pin the Redocly CLI version Tried moving components out of the root document (per libopenapi's nested_files example) so vacuum's own bundler could produce clean names, but any component declared via $ref inside components.* still gets a __<parent>-suffixed twin regardless of collisions elsewhere, so vacuum's --composed bundler can't cleanly bundle this spec. Pin the already-working Redocly fallback to an exact version instead of @latest. * fix(api): bundle the OpenAPI spec with vacuum vacuum's --composed bundler suffixes any component reached via a $ref written directly inside the root document's own components.* block, regardless of collisions elsewhere. Dropping the root-level schemas/ parameters/responses declarations (keeping only securitySchemes, and leaving every component file under api/openapi/components/ untouched) lets vacuum bundle cleanly with no __ suffixes, going back to Go-only tooling. Components nothing references yet (ListMeta, offset, limit, BadRequest, Unauthorized, Forbidden, NotFound) are absent from the bundle until a later task's operation references them. * fix(api): make spec lint rules cover all schemas and error codes nd-schema-property-descriptions targeted $.components.schemas, but our schemas live in path/response files, not the root document, so it was dead code; switched to $..properties[*] to walk every resolved schema wherever it ends up. nd-error-responses-are-problems only checked a hardcoded status-code list; switched to a patternProperties schema matching the full 4xx/5xx range. Also: api-diff now diffs against the merge-base with API_DIFF_BASE (falling back to its tip with a notice if no merge-base exists), gen no longer depends on api-gen until Task 3 wires up oapi-codegen, and api-lint suppresses vacuum's banner. * feat(api): embed the bundled OpenAPI spec and expose its version * feat(api): generate the v1 server interface with oapi-codegen * feat(api): add RFC 9457 problem responses for API v1 * feat(api): add API v1 router with /server discovery and spec routes * fix(api): serve the OpenAPI document without range support * feat(api): mount API v1 behind the DevAPIv1 flag * chore(ci): lint, regenerate and diff the OpenAPI v1 spec * refactor(api): tighten spec version access, lint rules and test naming * refactor(api): simplify spec routes, tests and OpenAPI tooling Share one If-None-Match parser (utils/req) between the image and spec routes, declare the YAML spec response as an object so tests need no decoder override, and reuse ETag/304 spec components. Install the OpenAPI tools only when missing or at a different version, fail api-diff when its base ref does not exist, and in CI cache the tools, fold regeneration into the go generate check, and fetch only the PR base commit for the breaking-change gate. * refactor(api): raise the list limit maximum to 2000 and drop the flag test * feat(api): treat added enum values as non-breaking Enums in API v1 are open: clients must accept unknown values. api-diff now downgrades response-property-enum-value-added to INFO, while removing a value from a request enum stays breaking. * feat(api): gate breaking changes on x-stability-level Every operation declares x-stability-level (alpha, beta, stable). oasdiff ignores breaking changes to alpha operations and rejects lowering a level, so unreleased endpoints can evolve while beta and stable ones stay additive. All current operations start as alpha. * feat(api): declare loginMethods as an enum Prefix generated enum constants with their type name so enums sharing a value (for example password) cannot collide in package apiv1. * feat(api): send Allow on 405 and answer HEAD wherever GET is routed chi only sets Allow in its default 405 handler, so the problem-format handler now builds it by matching each method against the v1 router. HEAD requests fall back to the GET route, as RFC 9110 expects. * refactor(api): hash the spec ETag with xxh3 The bytes are compiled in, and the digest was truncated to 64 bits anyway, so this matches the artwork ETags instead of paying for cryptographic strength we discard. * docs(api): explain the about:blank problem type * feat(api): make code the problem identifier and omit a blank type RFC 9457 says clients switch on the type URI, but no adopter surveyed ships both a populated type and a separate code. Declare code as an enum, and send type only once a problem has semantics of its own. * fix(api): advertise the configured base path in the served OpenAPI spec With BaseURL=/music the API is mounted at /music/api/v1, but the spec told clients to call /api/v1 at the host root. The server now rewrites servers[0].url to BasePath + /api/v1 when it serves the document. Relative server URLs were tested first: "." and "../v1" work in openapi-generator, Swagger UI and Redoc, but Scalar resolves them against the page origin, so it breaks even without a base path. The committed bundle keeps /api/v1, and a test pins that it appears exactly once, which the rewrite relies on.
1219 lines
44 KiB
Go
1219 lines
44 KiB
Go
package conf
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/go-viper/encoding/ini"
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"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/navidrome/navidrome/utils/slice"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type configOptions struct {
|
|
ConfigFile string `conf:"-"`
|
|
Address string
|
|
Port int
|
|
UnixSocketPerm string
|
|
EnforceNonRootUser bool
|
|
MusicFolder string
|
|
DataFolder Dir
|
|
CacheFolder Dir
|
|
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
|
|
EnableDownloads bool
|
|
EnableExternalServices bool
|
|
EnableM3UExternalAlbumArt bool
|
|
EnableInsightsCollector bool
|
|
EnableScheduledDBAnalyze 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"`
|
|
Matcher matcherOptions `json:",omitzero"`
|
|
RecentlyAddedByModTime bool
|
|
PreferSortTags bool
|
|
EnableNaturalSorting 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
|
|
MaxImageUploadSize string
|
|
MaxImageSize string
|
|
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
|
|
UIPlaybackReportInterval time.Duration
|
|
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"`
|
|
Transcoding transcodingOptions `json:",omitzero"`
|
|
LastFM lastfmOptions `json:",omitzero"`
|
|
Deezer deezerOptions `json:",omitzero"`
|
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
|
Jellyfin jellyfinOptions `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
|
|
DevArtworkThrottleBuffered bool
|
|
DevArtworkWorkerConcurrency int
|
|
DevArtworkExternalMaxRPS int
|
|
DevArtistInfoTimeToLive time.Duration
|
|
DevAlbumInfoTimeToLive time.Duration
|
|
DevExternalScanner bool
|
|
DevScannerThreads uint
|
|
DevSelectiveWatcher bool
|
|
DevInsightsInitialDelay time.Duration
|
|
DevEnablePlayerInsights bool
|
|
DevEnablePluginsInsights bool
|
|
DevPluginCompilationTimeout time.Duration
|
|
DevExternalArtistFetchMultiplier float64
|
|
DevPreserveUnicodeInExternalCalls bool
|
|
DevEnableMediaFileProbe bool
|
|
DevAPIv1 bool
|
|
}
|
|
|
|
type scannerOptions struct {
|
|
Enabled bool
|
|
Schedule string
|
|
WatcherWait time.Duration
|
|
ScanOnStartup bool
|
|
Extractor string
|
|
ArtistJoiner string
|
|
ArtistSplitExceptions []string // Artist names never split by tag separators
|
|
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
|
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
|
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
|
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
|
PurgeMissing string // Values: "never", "always", "full"
|
|
}
|
|
|
|
type transcodingOptions struct {
|
|
MaxConcurrent int
|
|
MaxConcurrentPerUser int
|
|
EnableCancellation bool
|
|
}
|
|
|
|
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 `conf:"-"` // Computed from Language, split by comma
|
|
}
|
|
|
|
type deezerOptions struct {
|
|
Enabled bool
|
|
Language string
|
|
|
|
// Computed values
|
|
Languages []string `conf:"-"` // Computed from Language, split by comma
|
|
}
|
|
|
|
type listenBrainzOptions struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
ArtistAlgorithm string
|
|
TrackAlgorithm string
|
|
}
|
|
|
|
type jellyfinOptions struct {
|
|
Enabled bool
|
|
ServerName string
|
|
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
|
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
|
ExposedPublicUsers string
|
|
AutoDiscovery bool
|
|
QuickConnect bool
|
|
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
|
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
|
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
|
MaxConcurrentStreams int
|
|
}
|
|
|
|
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 Dir
|
|
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 Dir
|
|
CacheSize string
|
|
AutoReload bool
|
|
LogLevel string
|
|
}
|
|
|
|
type extAuthOptions struct {
|
|
TrustedSources string
|
|
UserHeader string
|
|
LogoutURL string
|
|
}
|
|
|
|
type searchOptions struct {
|
|
Backend string
|
|
FullString bool
|
|
}
|
|
|
|
type matcherOptions struct {
|
|
PreferStarred bool
|
|
FuzzyThreshold int
|
|
}
|
|
|
|
// 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 getEUID = os.Geteuid
|
|
|
|
var currentGOOS = func() string {
|
|
return runtime.GOOS
|
|
}
|
|
|
|
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
|
// so callers cannot infer it from the certificate alone.
|
|
func (c *configOptions) TLSEnabled() bool {
|
|
return c.TLSCert != "" && c.TLSKey != ""
|
|
}
|
|
|
|
var (
|
|
Server = &configOptions{}
|
|
hooks []func()
|
|
)
|
|
|
|
// SnapshotConfig returns a function that restores Server to its current state.
|
|
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
|
|
func SnapshotConfig() func() {
|
|
snapshot, err := json.Marshal(Server)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
|
|
}
|
|
return func() {
|
|
var restored configOptions
|
|
if err := json.Unmarshal(snapshot, &restored); err != nil {
|
|
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
|
|
}
|
|
Server = &restored
|
|
}
|
|
}
|
|
|
|
func LoadFromFile(confFile string) {
|
|
viper.SetConfigFile(confFile)
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
logFatal("Error reading config file:", err)
|
|
}
|
|
Load(true)
|
|
}
|
|
|
|
func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) {
|
|
if val.Nanoseconds() < 0 {
|
|
log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original)
|
|
*val = original
|
|
}
|
|
}
|
|
|
|
func Load(noConfigDump bool) {
|
|
parseIniFileConfiguration()
|
|
remapEnvVarKeysFromConfig()
|
|
|
|
// Map deprecated options to their new names for backwards compatibility
|
|
for _, o := range deprecatedOptions {
|
|
if o.replacement != "" {
|
|
mapDeprecatedOption(o.name, o.replacement)
|
|
}
|
|
}
|
|
|
|
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
|
mapstructure.ComposeDecodeHookFunc(
|
|
mapstructure.TextUnmarshallerHookFunc(),
|
|
mapstructure.StringToTimeDurationHookFunc(),
|
|
mapstructure.StringToSliceHookFunc(","),
|
|
),
|
|
))
|
|
if err != nil {
|
|
logFatal("Error parsing config:", err)
|
|
}
|
|
|
|
// Validate non-root user early, before any filesystem operations
|
|
if err := validateEnforceNonRootUser(); err != nil {
|
|
logFatal(err)
|
|
}
|
|
|
|
if Server.CacheFolder.String() == "" {
|
|
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
|
|
}
|
|
|
|
if Server.Plugins.Enabled {
|
|
if Server.Plugins.Folder.String() == "" {
|
|
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
|
|
} else {
|
|
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
|
|
}
|
|
}
|
|
|
|
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
|
if Server.DbPath == "" {
|
|
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
|
|
}
|
|
|
|
out := os.Stderr
|
|
if Server.LogFile != "" {
|
|
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
|
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
|
}
|
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
|
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)
|
|
|
|
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
|
|
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
|
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
|
|
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
|
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
|
|
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
|
|
|
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
|
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
|
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
|
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
|
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
|
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
|
|
|
// Log deprecated, removed and unknown options
|
|
for _, o := range deprecatedOptions {
|
|
logDeprecatedOptions(o.name, o.replacement)
|
|
}
|
|
logRemovedOptions(removedOptions...)
|
|
logUnknownOptions()
|
|
|
|
err = run.Sequentially(
|
|
validateScanSchedule,
|
|
validateBackupSchedule,
|
|
validatePlaylistsPath,
|
|
validatePurgeMissingOption,
|
|
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
|
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
|
)
|
|
if err != nil {
|
|
logFatal(err)
|
|
}
|
|
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
|
|
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
|
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
|
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
|
}
|
|
|
|
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
|
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
|
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
|
if imgSize < uploadSize {
|
|
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
|
Server.MaxImageSize = Server.MaxImageUploadSize
|
|
}
|
|
|
|
// Call init hooks
|
|
for _, hook := range hooks {
|
|
hook()
|
|
}
|
|
}
|
|
|
|
// deprecatedOptions still work, but will be removed in a future release. An empty
|
|
// replacement means the option is now ignored.
|
|
var deprecatedOptions = []struct{ name, replacement string }{
|
|
{"Scanner.GenreSeparators", ""},
|
|
{"Scanner.GroupAlbumReleases", ""},
|
|
{"DevEnableBufferedScrobble", ""},
|
|
{"SearchFullString", "Search.FullString"},
|
|
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
|
|
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
|
|
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
|
|
{"CoverJpegQuality", "CoverArtQuality"},
|
|
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
|
|
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
|
|
}
|
|
|
|
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
|
|
|
|
func logDeprecatedOptions(oldName, newName string) {
|
|
envVar := envVarName(oldName)
|
|
newEnvVar := envVarName(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 := envVarName(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 := ndKeyToCanonical(key)
|
|
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
|
canonicalName := canonicalOptionName(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, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
|
|
))
|
|
return
|
|
}
|
|
|
|
viper.Set(canonicalKey, viper.Get(key))
|
|
// Unknown keys get no advice here, logUnknownOptions reports them instead
|
|
if canonicalName != "" {
|
|
_, _ = 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, canonicalName,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
|
|
if viper.IsSet(legacyName) && !explicitlySet(newName) {
|
|
viper.Set(newName, viper.Get(legacyName))
|
|
}
|
|
}
|
|
|
|
// explicitlySet reports whether the user provided the option, ignoring defaults,
|
|
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
|
|
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
|
|
func explicitlySet(name string) bool {
|
|
envVar := envVarName(name)
|
|
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
|
|
}
|
|
|
|
func envVarName(option string) string {
|
|
if option == "" {
|
|
return ""
|
|
}
|
|
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
|
}
|
|
|
|
func logUnknownOptions() {
|
|
for _, key := range unknownConfigKeys() {
|
|
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
|
|
if matches := suggestOptions(key); len(matches) > 0 {
|
|
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
|
|
}
|
|
log.Warn(msg)
|
|
}
|
|
}
|
|
|
|
// suggestOptions returns the known options sharing the last segment with key,
|
|
// catching options written outside their section.
|
|
func suggestOptions(key string) []string {
|
|
key = strings.ToLower(key)
|
|
leaf := leafKey(key)
|
|
canonical, _ := configKeys()
|
|
var matches []string
|
|
for known, name := range canonical {
|
|
// Removed options are known only so they get their own warning, never suggest them
|
|
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
|
|
matches = append(matches, name)
|
|
}
|
|
}
|
|
slices.Sort(matches)
|
|
return matches
|
|
}
|
|
|
|
func leafKey(key string) string {
|
|
return key[strings.LastIndex(key, ".")+1:]
|
|
}
|
|
|
|
// unknownConfigKeys returns config file keys that don't match any known option, so
|
|
// typos and options written outside their section don't fail silently.
|
|
func unknownConfigKeys() []string {
|
|
// INI files keep the original [default] section alongside the merged one
|
|
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
|
|
|
|
var unknown []string
|
|
for _, key := range viper.AllKeys() {
|
|
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
|
|
continue
|
|
}
|
|
if skipDefault && strings.HasPrefix(key, "default.") {
|
|
continue
|
|
}
|
|
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
|
|
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
|
|
continue
|
|
}
|
|
unknown = append(unknown, key)
|
|
}
|
|
slices.Sort(unknown)
|
|
return asWrittenInConfigFile(unknown)
|
|
}
|
|
|
|
func ndKeyToCanonical(key string) string {
|
|
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
|
|
}
|
|
|
|
// canonicalOptionName returns the documented spelling of a known option key, or ""
|
|
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
|
|
func canonicalOptionName(key string) string {
|
|
keys, prefixes := configKeys()
|
|
if name, ok := keys[key]; ok {
|
|
return name
|
|
}
|
|
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
|
|
return toPascalCase(key)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// asWrittenInConfigFile restores the casing the keys have in the config file, as
|
|
// viper lowercases every key it loads.
|
|
func asWrittenInConfigFile(keys []string) []string {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(viper.ConfigFileUsed())
|
|
if err != nil {
|
|
return keys
|
|
}
|
|
casing := map[string]string{}
|
|
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
|
|
for segment := range strings.SplitSeq(match[1], ".") {
|
|
lower := strings.ToLower(segment)
|
|
casing[lower] = cmp.Or(casing[lower], segment)
|
|
}
|
|
}
|
|
return slice.Map(keys, func(key string) string {
|
|
segments := strings.Split(key, ".")
|
|
for i, s := range segments {
|
|
segments[i] = cmp.Or(casing[s], s)
|
|
}
|
|
return strings.Join(segments, ".")
|
|
})
|
|
}
|
|
|
|
// Matches keys and section headers in all supported config formats.
|
|
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
|
|
|
|
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
|
|
// plus the prefixes of free-form map options (Tags, DevLogLevels).
|
|
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
|
|
keys := map[string]string{}
|
|
var prefixes []string
|
|
|
|
var collect func(t reflect.Type, prefix string)
|
|
collect = func(t reflect.Type, prefix string) {
|
|
for field := range t.Fields() {
|
|
// `conf:"-"` marks values computed during Load, not settable in the config
|
|
if !field.IsExported() || field.Tag.Get("conf") == "-" {
|
|
continue
|
|
}
|
|
name := prefix + field.Name
|
|
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
|
|
collect(field.Type, name+".")
|
|
continue
|
|
}
|
|
lower := strings.ToLower(name)
|
|
keys[lower] = name
|
|
if field.Type.Kind() == reflect.Map {
|
|
prefixes = append(prefixes, lower+".")
|
|
}
|
|
}
|
|
}
|
|
collect(reflect.TypeFor[configOptions](), "")
|
|
|
|
for _, o := range deprecatedOptions {
|
|
keys[strings.ToLower(o.name)] = o.name
|
|
}
|
|
for _, o := range removedOptions {
|
|
keys[strings.ToLower(o)] = o
|
|
}
|
|
return keys, prefixes
|
|
})
|
|
|
|
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
|
|
|
// 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 {
|
|
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, 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)
|
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateByteSize(name, value string) func() error {
|
|
return func() error {
|
|
size, err := humanize.ParseBytes(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
|
|
}
|
|
if size == 0 {
|
|
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
|
|
}
|
|
if size > math.MaxInt64 {
|
|
return fmt.Errorf("invalid %s %q: value is too large", name, value)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func validateEnforceNonRootUser() error {
|
|
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
|
|
return nil
|
|
}
|
|
|
|
if getEUID() == 0 {
|
|
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
|
|
}
|
|
|
|
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.String() == "" || 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 {
|
|
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
|
|
}
|
|
return schedule, nil
|
|
}
|
|
|
|
// 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 {
|
|
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
|
}
|
|
if u.Host == "" || u.Opaque != "" {
|
|
return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
|
}
|
|
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("enforcenonrootuser", false)
|
|
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("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", consts.DefaultSmartRefresh)
|
|
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("matcher.preferstarred", true)
|
|
viper.SetDefault("matcher.fuzzythreshold", 85)
|
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
|
viper.SetDefault("prefersorttags", false)
|
|
viper.SetDefault("enablenaturalsorting", 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", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.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("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
|
viper.SetDefault("enableartworkupload", true)
|
|
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
|
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
|
viper.SetDefault("enablesharing", true)
|
|
viper.SetDefault("shareurl", "")
|
|
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
|
|
viper.SetDefault("defaultdownloadableshare", false)
|
|
viper.SetDefault("gatrackingid", "")
|
|
viper.SetDefault("enableinsightscollector", true)
|
|
viper.SetDefault("enablescheduleddbanalyze", true)
|
|
viper.SetDefault("enablelogredacting", true)
|
|
viper.SetDefault("authrequestlimit", 5)
|
|
viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength)
|
|
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.artistsplitexceptions", []string{})
|
|
viper.SetDefault("scanner.genreseparators", "")
|
|
viper.SetDefault("scanner.groupalbumreleases", false)
|
|
viper.SetDefault("scanner.followsymlinks", true)
|
|
viper.SetDefault("scanner.ignoredotfolders", 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("transcoding.maxconcurrent", 0)
|
|
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
|
viper.SetDefault("transcoding.enablecancellation", false)
|
|
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("jellyfin.enabled", false)
|
|
viper.SetDefault("jellyfin.servername", "")
|
|
viper.SetDefault("jellyfin.autodiscovery", false)
|
|
viper.SetDefault("jellyfin.quickconnect", true)
|
|
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("devapiv1", false)
|
|
viper.SetDefault("devoffsetoptimize", 50000)
|
|
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
|
// scrobbles and the UI. See MaxOpenConns.
|
|
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
|
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
|
viper.SetDefault("devartworkthrottlebuffered", true)
|
|
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
|
|
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
|
|
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
|
|
// External RPS gates outbound calls to third-party services (per service); it is bounded by
|
|
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
|
|
viper.SetDefault("devartworkexternalmaxrps", 2)
|
|
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("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 ""
|
|
}
|
|
|
|
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
|
|
// Subsonic, Jellyfin, native API, UI).
|
|
//
|
|
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
|
|
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
|
|
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
|
|
// count is only loosely related to core count, and the floor is what matters on small machines.
|
|
func MaxOpenConns() int {
|
|
return max(4, runtime.NumCPU())
|
|
}
|