mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(jellyfin): stream /Items responses to prevent OOM on large libraries
Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio
with Fields=MediaSources and no Limit. On a large library this built the whole
result set — every MediaFile and every BaseItemDto, each fat with MediaSources —
in memory, and json.Encoder then buffered the entire ~200MB response before
writing a byte. Measured on a 96k-track library, one such request peaked near
1.6GB RSS; in a memory-limited container the resulting slowness made clients
retry, stacking concurrent full-library builds until the process was OOM-killed.
Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor),
mapping and encoding one row at a time, so peak memory is bounded to about one
item regardless of library size. The cursor is opened lazily, when streaming
begins, so it doesn't hold a DB connection across the CountAll and ServerId
lookups that run first (ServerId can write on first use and would deadlock
against an open reader). Pagination still works — the cursor query carries
LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays
the full count. Other materialized responses stream their JSON too, avoiding the
encoder's buffer-the-whole-output cost.
Also bound artwork image concurrency with the same server.ThrottleBacklog that
Subsonic's getCoverArt uses, so a burst of image requests during sync can't
exhaust memory through concurrent decode/resize.
Verified against a copy of a 96k-track production database: byte-for-byte
identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s.
* fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200
Addresses code review: a streamed /Items response commits HTTP 200 before an
error can occur, so failures were surfacing as misleadingly successful bodies.
- A cursor-open failure (e.g. a busy DB under connection pressure) is now
surfaced before the first byte is written: the cursor open is deferred into
the item source and run in writeItems after the ServerId lookup but before the
envelope, returning a clean 500 the client can retry — not a 200 with an empty
item list.
- A mid-stream (row-scan) error now aborts without closing the JSON envelope,
leaving the body malformed. A truncated-but-valid response would let a sync
client (Finamp) treat the short list as the whole library and prune local
tracks; malformed JSON forces the client's parser to fail and retry.
* refactor(jellyfin): stream every collection endpoint from a DB cursor
Streaming was applied only to the song listing, which left two write paths, two
ServerId stamping mechanisms and a listXxx return-type split ("songs is
special") that made the code hard to follow.
Add GetCursor to the album, artist, genre and playlist repositories, mirroring
MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor
yields identical, fully-hydrated rows (hydration happens per-row in PostScan,
and toModels is only a deref loop). The playlist cursor keeps GetAll's
owner/public visibility filter. Each repository test asserts the cursor yields
exactly what GetAll returns, including Max/Offset.
With cursors everywhere, every listXxx returns itemsResult and every collection
streams through one writer:
- listAlbums, listArtists and listPlaylists now stream from their cursors;
search paths stay materialized (Search returns a slice).
- listGenres stays materialized: its total is the length of the full list and it
paginates in memory, so there is nothing for a cursor to page over.
- api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers
funnel into the same writer; streamQueryResult and wrapResult are gone.
- /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via
writeItemsArray, which shares the item loop and stamping. That also closes the
same unbounded hole /Items had: limit=0 disabled its LIMIT.
- ServerId is now stamped in exactly one place, so api.ok only handles single,
non-collection payloads.
Verified against a copy of a 96k-track production database: every endpoint
byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB;
time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s.
* refactor(jellyfin): route every response through api.ok
Handlers were split between api.ok and api.writeItems with no clear rule for
which to call. api.ok now accepts itemsResult too, so it is the single entry
point: callers hand it whatever they have and it routes collections (cursor
-backed or materialized) to the streaming writer. writeItems is reached only
through api.ok now. The one exception is /Items/Latest, which returns a bare
JSON array rather than a QueryResult envelope and so writes directly — noted in
both doc comments.
Also drop GenreRepository.GetCursor: listGenres derives its total from the
length of the full list and paginates in memory, so there is nothing for a
cursor to page over, leaving the method unused.
* fix(jellyfin): stream the unbounded multi-type /Items merge
The multi-type merge capped each per-type query at offset+limit only when a
Limit was given. Without one (Finamp's favorites screen sends multi-type), every
type ran an unbounded query and collect() drained each cursor into a slice — so
/Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album
and every song, the same OOM class this branch set out to fix. The comment
claiming the set was "capped at offset+limit per type" was wrong for limit=0.
Without a limit the merged page is just each type's rows in order minus the
first offset, which is exactly what chaining the per-type cursors yields, so
stream that instead of merging in memory. The bounded path is unchanged: with a
Limit each type holds at most offset+limit rows, so merging and paginating
across the combined list is safe. Cursors are opened one at a time (each pins a
DB connection until drained), with the first opened eagerly so the usual failure
is still a clean error before any byte is written.
Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with
no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s ->
0.6s, response byte-for-byte identical.
* refactor(jellyfin): parse /Items params into a struct
The /Items dispatcher was a single 90-line block that parsed a dozen params and
then threaded them positionally through three layers — queryItemsOfType took 11
arguments, listSongs and listAlbums 9 each — so reading any one of them meant
decoding a long argument list.
Parse once into an itemsQuery and pass that instead. queryItems now reads as the
four things it actually does: parse, the id/playlists-folder/playlist-parent
special cases, single-type dispatch, multi-type merge — with the playlist-parent
and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes
(ctx, opts, q).
No behavior change: parsing, ordering and the entityParent rule are unchanged,
and /Artists still passes favOnly=false explicitly by building the subset of the
query it uses.
* docs(jellyfin): trim comments on the streaming path
Cut the comments back to the non-obvious why: the deferred cursor open (the
ServerId write would deadlock against an open reader), the deliberate malformed
JSON on a mid-stream error, why chained opens one cursor at a time, why
listGenres stays materialized, and why the cursor helpers take the underlying
func type. Dropped the rest — restatements of the code, doc comments that
repeated a test's own name, and the GetCursor interface comments that the
existing MediaFileRepository.GetCursor does without.
* refactor(persistence): fold the cursor wrappers into a generic wrapCursor
wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were
the same twelve lines four times over, differing only in the type name, and the
playlist cursor had its own inline copy of the loop.
Add one generic wrapCursor. It takes an extractor func rather than a method on
an interface: a type parameter can't reach an embedded field, and methods that
only satisfy a generic constraint are reported by the unused linter. The
extractor also lets both type parameters be inferred, so call sites need no
explicit instantiation. Each entity keeps its named wrapper as a one-liner,
since the model cursor types are defined types and need the conversion — and the
existing wrapper tests call them directly.
The nil-row error now names the model type via %T ("unexpected nil model.Album")
instead of a hand-written per-entity string; the three tests asserting that
message are updated.
* fix(jellyfin): bound concurrent collection streams
A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced
response, where the old materialize-then-write path released it as soon as the query finished. The
pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take
every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track
production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the
other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention).
Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is
rejected: the same 20 streams now all complete and the worst write is 2.5ms.
- conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a
tunable, db already imports conf, and putting it in db would force server/jellyfin to import db
just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from
drifting apart.
- Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to
release its token early, which is right for artwork but would undo the streaming. chi's panics on a
non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap
instead of crashing the server at startup.
* refactor(jellyfin): move throttleStreams to middlewares.go
It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and
withPlayer rather than in api.go. Its tests move to middlewares_test.go with it,
keeping one test file per production file.
* fix(jellyfin): abandon the scan when the client stops reading
encodeItems discarded its write errors and relied on the final Flush to report
them, so once bufio's buffer filled and the flush failed, the loop still pulled
every remaining row through the cursor and serialized it for a client that was
gone. A test with a failing writer confirms it: all 20k items were drained.
That wastes CPU, and holds the cursor's pooled DB connection and a stream slot
(now a capped resource) for the length of a full scan nobody is reading. Check
the per-item writes so the first failure ends the scan; the fixed envelope
writes stay unchecked, since bufio latches for them anyway.
980 lines
35 KiB
Go
980 lines
35 KiB
Go
package conf
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"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/spf13/viper"
|
|
)
|
|
|
|
type configOptions struct {
|
|
ConfigFile string
|
|
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
|
|
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
|
|
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
|
|
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
|
|
}
|
|
|
|
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 // 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 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
|
|
// 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
|
|
}
|
|
|
|
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 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")
|
|
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
|
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
|
|
|
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, 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,
|
|
validateMaxImageUploadSize,
|
|
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)
|
|
|
|
// 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")
|
|
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
|
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
|
|
|
// 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 {
|
|
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 validateMaxImageUploadSize() error {
|
|
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
|
|
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
|
|
}
|
|
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", 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("matcher.preferstarred", true)
|
|
viper.SetDefault("matcher.fuzzythreshold", 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", ".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("enablesharing", true)
|
|
viper.SetDefault("shareurl", "")
|
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
|
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", 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.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("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)
|
|
// 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)
|
|
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())
|
|
}
|