ae1e0ddb11
* feat(subsonic): implement transcode decision logic and codec handling for media files Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): update codec limitation structure and decision logic for improved clarity Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): simplify container alias handling in matchesContainer function Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests Signed-off-by: Deluan <deluan@navidrome.org> * feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance logging for transcode decision process and client info conversion Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance transcoding config lookup logic for audio codecs Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance transcoding options with sample rate support and improve command handling Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): add bit depth support for audio transcoding and enhance related logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance AAC command handling and support for audio channels in streaming Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): update default command handling and add codec support for transcoding Signed-off-by: Deluan <deluan@navidrome.org> * fix: implement noopDecider for transcoding decision handling in tests Signed-off-by: Deluan <deluan@navidrome.org> * fix: address review findings for OpenSubsonic transcoding PR Fix multiple issues identified during code review of the transcoding extension: add missing return after error in shared stream handler preventing nil pointer panic, replace dead r.Body nil check with MaxBytesReader size limit, distinguish not-found from other DB errors, fix bpsToKbps integer truncation with rounding, add "pcm" to isLosslessFormat for consistency with model.IsLossless(), add sampleRate/bitDepth/channels to streaming log, fix outdated test comment, and add tests for conversion functions and GetTranscodeStream parameter passing. * feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters Signed-off-by: Deluan <deluan@navidrome.org> * fix: small issues Updated mock AAC transcoding command to use the new default (ipod with fragmented MP4) matching the migration, ensuring tests exercise the same buildDynamicArgs code path as production. Improved archiver test mock to match on the whole StreamRequest struct instead of decomposing fields, making it resilient to future field additions. Added named constants for JWT claim keys in the transcode token and wrapped ParseTranscodeParams errors with ErrTokenInvalid for consistency. Documented the IsLossless BitDepth fallback heuristic as temporary until Codec column is populated. Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcoding): adapt transcode claims to struct-based auth.Claims Updated transcode token handling to use the struct-based auth.Claims introduced on master, replacing the previous map[string]any approach. Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in ClaimsFromToken for numeric claims that lose their Go type during JWT string serialization. Also added the missing lyrics parameter to all subsonic.New() calls in test files. * feat(model): add ProbeData field and UpdateProbeData repository method Add probe_data TEXT column to media_file for caching ffprobe results. Add UpdateProbeData to MediaFileRepository interface and implementations. Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints. * feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract codec, profile, bitrate, sample rate, bit depth, and channels. Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth. Normalize "unknown" profile to empty string. All parseProbeOutput tests use real ffprobe JSON from actual files. * feat(transcoding): integrate ffprobe into transcode decisions Add ensureProbed to probe media files on first transcode decision, caching results in probe_data. Build SourceStream from probe data with fallback to tag-based metadata. Refactor decision logic to pass StreamDetails instead of MediaFile, enabling codec profile limitations (e.g., audioProfile) to use probe data. Add normalizeProbeCodec to map ffprobe codec names (dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm). NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated. * feat(transcoding): add DevEnableMediaFileProbe config flag Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe- based media file probing as a safety fallback. When disabled, the decider uses tag-based metadata from the scanner instead. * test(transcode): add ensureProbed unit tests Test probing when ProbeData is empty, skipping when already set, error propagation from ffprobe, and DevEnableMediaFileProbe flag. * refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream Move ffprobe arguments to a probeAudioStreamCmd constant, following the same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to only probe the first audio stream, avoiding unnecessary parsing of video and artwork streams. Derive the ffprobe binary path safely using filepath.Dir/Base instead of replacing within the full path string. * refactor(transcode): decouple transcode token claims from auth.Claims Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) from auth.Claims, which is shared with session and share tokens. Transcode tokens are signed parameter-passing tokens, not authentication tokens, so coupling them to auth created misleading dependencies. The transcode package now owns its own JWT claim serialization via Decision.toClaimsMap() and paramsFromToken(), using generic auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight tokens remain compatible. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcode): simplify code after review Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite checkIntLimitation as a one-liner delegating to applyIntLimitation. Return probe result from ensureProbed to avoid redundant JSON round-trip. Extract toResponseStreamDetails helper and mediaTypeSong constant in the API layer, and use transcode.ProtocolHTTP constant instead of hardcoded string. Signed-off-by: Deluan <deluan@navidrome.org> * fix(ffmpeg): enhance bit_rate parsing logic for audio streams Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcode): improve code review findings across transcode implementation - Fix parseProbeData to return nil on JSON unmarshal failure instead of a zero-valued struct, preventing silent degradation of source stream details - Use probe-resolved codec for lossless detection in buildSourceStream instead of the potentially stale scanner data - Remove MediaFile.IsLossless() (dead code) and consolidate lossless detection in isLosslessFormat(), using codec name only — bit depth is not reliable since lossy codecs like ADPCM report non-zero values - Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack) - Guard bpsToKbps against negative input values - Fix misleading comment in buildTemplateArgs about conditional injection - Avoid leaking internal error details in Subsonic API responses - Add missing test for ErrNotFound branch in GetTranscodeDecision - Add TODO for hardcoded protocol in toResponseStreamDetails * refactor(transcode): streamline transcoding command lookup and format resolution Signed-off-by: Deluan <deluan@navidrome.org> * feat(transcode): implement server-side transcoding override for player formats Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcode): honor bit depth and channel constraints in transcoding selection selectTranscodingOptions only checked sample rate when deciding whether same-format transcoding was needed, ignoring requested bit depth and channel reductions. This caused the streamer to return raw audio when the transcode decision requested downmix or bit-depth conversion. * refactor(transcode): unify streaming decision engine via MakeDecision Move transcoding decision-making out of mediaStreamer and into the subsonic Stream/Download handlers, using transcode.Decider.MakeDecision as the single decision engine. This eliminates selectTranscodingOptions and the mismatch between decision and streaming code paths (decision used LookupTranscodeCommand with built-in fallbacks, while streaming used FindByFormat which only checked the DB). - Add DecisionOptions with SkipProbe to MakeDecision so the legacy streaming path never calls ffprobe - Add buildLegacyClientInfo to translate legacy stream params (format, maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo - Add resolveStreamRequest on the subsonic Router to resolve legacy params into a fully specified StreamRequest via MakeDecision - Simplify DoStream to a dumb executor that receives pre-resolved params - Remove selectTranscodingOptions entirely Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest Moved MediaStreamer, Stream, TranscodingCache and related types from core/media_streamer.go into core/transcode/, eliminating the duplicate StreamRequest type. The transcode.StreamRequest now carries all fields (ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and ResolveStream returns a fully-populated value, removing manual field copying at every call site. Also moved buildLegacyClientInfo into the transcode package alongside ResolveStream, and unexported ParseTranscodeParams since it was only used internally by ValidateTranscodeParams. * refactor(transcode): rename Decider methods and unexport Params type Rename ResolveStream → ResolveRequest and ValidateTranscodeParams → ResolveRequestFromToken for clarity and consistency. The new ResolveRequestFromToken returns a StreamRequest directly (instead of the intermediate Params type), eliminating manual Params→StreamRequest conversion in callers. Unexport Params to params since it is now only used internally for JWT token parsing. * test(transcode): remove redundant tests and use constants Remove tests that duplicate coverage from integration-level tests (toClaimsMap, paramsFromToken round-trips, applyServerOverride direct call, duplicate 410 handler test). Replace raw "http" strings with ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into DescribeTable. * refactor(transcode): split oversized files into focused modules Split transcode.go and transcode_test.go into focused files by concern: - decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe) - token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken) - legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest) - codec_test.go: isLosslessFormat and normalizeProbeCodec tests - token_test.go: token round-trip and ResolveRequestFromToken tests Moved the Decider interface from types.go to decider.go to keep it near its implementation, and cleaned up types.go to contain only pure type definitions and constants. No public API changes. * refactor(transcode): reorder parameters in applyServerOverride function Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): add NewTestStream function and implement spyStreamer for testing Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
823 lines
28 KiB
Go
823 lines
28 KiB
Go
package conf
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/go-viper/encoding/ini"
|
|
"github.com/kr/pretty"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/utils/run"
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type configOptions struct {
|
|
ConfigFile string
|
|
Address string
|
|
Port int
|
|
UnixSocketPerm string
|
|
MusicFolder string
|
|
DataFolder string
|
|
CacheFolder string
|
|
DbPath string
|
|
LogLevel string
|
|
LogFile string
|
|
SessionTimeout time.Duration
|
|
BaseURL string
|
|
BasePath string
|
|
BaseHost string
|
|
BaseScheme string
|
|
TLSCert string
|
|
TLSKey string
|
|
UILoginBackgroundURL string
|
|
UIWelcomeMessage string
|
|
MaxSidebarPlaylists int
|
|
EnableTranscodingConfig bool
|
|
EnableTranscodingCancellation bool
|
|
EnableDownloads bool
|
|
EnableExternalServices bool
|
|
EnableM3UExternalAlbumArt bool
|
|
EnableInsightsCollector bool
|
|
EnableMediaFileCoverArt bool
|
|
TranscodingCacheSize string
|
|
ImageCacheSize string
|
|
AlbumPlayCountMode string
|
|
EnableArtworkPrecache bool
|
|
AutoImportPlaylists bool
|
|
DefaultPlaylistPublicVisibility bool
|
|
PlaylistsPath string
|
|
SmartPlaylistRefreshDelay time.Duration
|
|
AutoTranscodeDownload bool
|
|
DefaultDownsamplingFormat string
|
|
Search searchOptions `json:",omitzero"`
|
|
SimilarSongsMatchThreshold int
|
|
RecentlyAddedByModTime bool
|
|
PreferSortTags bool
|
|
IgnoredArticles string
|
|
IndexGroups string
|
|
FFmpegPath string
|
|
MPVPath string
|
|
MPVCmdTemplate string
|
|
CoverArtPriority string
|
|
CoverJpegQuality int
|
|
ArtistArtPriority string
|
|
LyricsPriority string
|
|
EnableGravatar bool
|
|
EnableFavourites bool
|
|
EnableStarRating bool
|
|
EnableUserEditing bool
|
|
EnableCoverArtUpload bool
|
|
EnableSharing bool
|
|
ShareURL string
|
|
DefaultShareExpiration time.Duration
|
|
DefaultDownloadableShare bool
|
|
DefaultTheme string
|
|
DefaultLanguage string
|
|
DefaultUIVolume int
|
|
UISearchDebounceMs int
|
|
EnableReplayGain bool
|
|
EnableCoverAnimation bool
|
|
EnableNowPlaying bool
|
|
GATrackingID string
|
|
EnableLogRedacting bool
|
|
AuthRequestLimit int
|
|
AuthWindowLength time.Duration
|
|
PasswordEncryptionKey string
|
|
ExtAuth extAuthOptions
|
|
Plugins pluginsOptions
|
|
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
|
Prometheus prometheusOptions `json:",omitzero"`
|
|
Scanner scannerOptions `json:",omitzero"`
|
|
Jukebox jukeboxOptions `json:",omitzero"`
|
|
Backup backupOptions `json:",omitzero"`
|
|
PID pidOptions `json:",omitzero"`
|
|
Inspect inspectOptions `json:",omitzero"`
|
|
Subsonic subsonicOptions `json:",omitzero"`
|
|
LastFM lastfmOptions `json:",omitzero"`
|
|
Spotify spotifyOptions `json:",omitzero"`
|
|
Deezer deezerOptions `json:",omitzero"`
|
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
|
EnableScrobbleHistory bool
|
|
Tags map[string]TagConf `json:",omitempty"`
|
|
Agents string
|
|
|
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
|
DevLogLevels map[string]string `json:",omitempty"`
|
|
DevLogSourceLine bool
|
|
DevEnableProfiler bool
|
|
DevAutoCreateAdminPassword string
|
|
DevAutoLoginUsername string
|
|
DevActivityPanel bool
|
|
DevActivityPanelUpdateRate time.Duration
|
|
DevSidebarPlaylists bool
|
|
DevShowArtistPage bool
|
|
DevUIShowConfig bool
|
|
DevNewEventStream bool
|
|
DevOffsetOptimize int
|
|
DevArtworkMaxRequests int
|
|
DevArtworkThrottleBacklogLimit int
|
|
DevArtworkThrottleBacklogTimeout time.Duration
|
|
DevArtistInfoTimeToLive time.Duration
|
|
DevAlbumInfoTimeToLive time.Duration
|
|
DevExternalScanner bool
|
|
DevScannerThreads uint
|
|
DevSelectiveWatcher bool
|
|
DevInsightsInitialDelay time.Duration
|
|
DevEnablePlayerInsights bool
|
|
DevEnablePluginsInsights bool
|
|
DevPluginCompilationTimeout time.Duration
|
|
DevExternalArtistFetchMultiplier float64
|
|
DevOptimizeDB bool
|
|
DevPreserveUnicodeInExternalCalls bool
|
|
DevEnableMediaFileProbe bool
|
|
}
|
|
|
|
type scannerOptions struct {
|
|
Enabled bool
|
|
Schedule string
|
|
WatcherWait time.Duration
|
|
ScanOnStartup bool
|
|
Extractor string
|
|
ArtistJoiner string
|
|
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
|
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
|
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
|
PurgeMissing string // Values: "never", "always", "full"
|
|
}
|
|
|
|
type subsonicOptions struct {
|
|
AppendSubtitle bool
|
|
AppendAlbumVersion bool
|
|
ArtistParticipations bool
|
|
DefaultReportRealPath bool
|
|
EnableAverageRating bool
|
|
LegacyClients string
|
|
MinimalClients string
|
|
}
|
|
|
|
type TagConf struct {
|
|
Ignore bool `yaml:"ignore" json:",omitempty"`
|
|
Aliases []string `yaml:"aliases" json:",omitempty"`
|
|
Type string `yaml:"type" json:",omitempty"`
|
|
MaxLength int `yaml:"maxLength" json:",omitempty"`
|
|
Split []string `yaml:"split" json:",omitempty"`
|
|
Album bool `yaml:"album" json:",omitempty"`
|
|
}
|
|
|
|
type lastfmOptions struct {
|
|
Enabled bool
|
|
ApiKey string //nolint:gosec
|
|
Secret string //nolint:gosec
|
|
Language string
|
|
ScrobbleFirstArtistOnly bool
|
|
|
|
// Computed values
|
|
Languages []string // Computed from Language, split by comma
|
|
}
|
|
|
|
type spotifyOptions struct {
|
|
ID string
|
|
Secret string //nolint:gosec
|
|
}
|
|
|
|
type deezerOptions struct {
|
|
Enabled bool
|
|
Language string
|
|
|
|
// Computed values
|
|
Languages []string // Computed from Language, split by comma
|
|
}
|
|
|
|
type listenBrainzOptions struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
ArtistAlgorithm string
|
|
TrackAlgorithm string
|
|
}
|
|
|
|
type httpHeaderOptions struct {
|
|
FrameOptions string
|
|
}
|
|
|
|
type prometheusOptions struct {
|
|
Enabled bool
|
|
MetricsPath string
|
|
Password string //nolint:gosec
|
|
}
|
|
|
|
type AudioDeviceDefinition []string
|
|
|
|
type jukeboxOptions struct {
|
|
Enabled bool
|
|
Devices []AudioDeviceDefinition
|
|
Default string
|
|
AdminOnly bool
|
|
}
|
|
|
|
type backupOptions struct {
|
|
Count int
|
|
Path string
|
|
Schedule string
|
|
}
|
|
|
|
type pidOptions struct {
|
|
Track string
|
|
Album string
|
|
}
|
|
|
|
type inspectOptions struct {
|
|
Enabled bool
|
|
MaxRequests int
|
|
BacklogLimit int
|
|
BacklogTimeout int
|
|
}
|
|
|
|
type pluginsOptions struct {
|
|
Enabled bool
|
|
Folder string
|
|
CacheSize string
|
|
AutoReload bool
|
|
LogLevel string
|
|
}
|
|
|
|
type extAuthOptions struct {
|
|
TrustedSources string
|
|
UserHeader string
|
|
LogoutURL string
|
|
}
|
|
|
|
type searchOptions struct {
|
|
Backend string
|
|
FullString bool
|
|
}
|
|
|
|
var (
|
|
Server = &configOptions{}
|
|
hooks []func()
|
|
)
|
|
|
|
func LoadFromFile(confFile string) {
|
|
viper.SetConfigFile(confFile)
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
|
os.Exit(1)
|
|
}
|
|
Load(true)
|
|
}
|
|
|
|
func Load(noConfigDump bool) {
|
|
parseIniFileConfiguration()
|
|
|
|
// Map deprecated options to their new names for backwards compatibility
|
|
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
|
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
|
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
|
|
|
err := viper.Unmarshal(&Server)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if Server.CacheFolder == "" {
|
|
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
|
}
|
|
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if Server.Plugins.Enabled {
|
|
if Server.Plugins.Folder == "" {
|
|
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
|
}
|
|
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
|
if Server.DbPath == "" {
|
|
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
|
}
|
|
|
|
if Server.Backup.Path != "" {
|
|
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
out := os.Stderr
|
|
if Server.LogFile != "" {
|
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
|
os.Exit(1)
|
|
}
|
|
log.SetOutput(out)
|
|
}
|
|
|
|
log.SetLevelString(Server.LogLevel)
|
|
log.SetLogLevels(Server.DevLogLevels)
|
|
log.SetLogSourceLine(Server.DevLogSourceLine)
|
|
log.SetRedacting(Server.EnableLogRedacting)
|
|
|
|
err = run.Sequentially(
|
|
validateScanSchedule,
|
|
validateBackupSchedule,
|
|
validatePlaylistsPath,
|
|
validatePurgeMissingOption,
|
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
|
)
|
|
if err != nil {
|
|
os.Exit(1)
|
|
}
|
|
|
|
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
|
|
|
if Server.BaseURL != "" {
|
|
u, err := url.Parse(Server.BaseURL)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
|
os.Exit(1)
|
|
}
|
|
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)
|
|
|
|
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")
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
|
os.Exit(1)
|
|
}
|
|
cfg, ok := iniConfig["default"].(map[string]any)
|
|
if !ok {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
|
os.Exit(1)
|
|
}
|
|
err = viper.MergeConfigMap(cfg)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func disableExternalServices() {
|
|
log.Info("All external integrations are DISABLED!")
|
|
Server.EnableInsightsCollector = false
|
|
Server.EnableM3UExternalAlbumArt = false
|
|
Server.LastFM.Enabled = false
|
|
Server.Spotify.ID = ""
|
|
Server.Deezer.Enabled = false
|
|
Server.ListenBrainz.Enabled = false
|
|
Server.Agents = ""
|
|
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
|
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
}
|
|
}
|
|
|
|
func validatePlaylistsPath() error {
|
|
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
_, err := doublestar.Match(path, "")
|
|
if err != nil {
|
|
log.Error("Invalid PlaylistsPath", "path", path, err)
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseLanguages parses a comma-separated language string into a slice.
|
|
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
|
func parseLanguages(lang string) []string {
|
|
var languages []string
|
|
for l := range strings.SplitSeq(lang, ",") {
|
|
l = strings.TrimSpace(l)
|
|
if l != "" {
|
|
languages = append(languages, l)
|
|
}
|
|
}
|
|
if len(languages) == 0 {
|
|
return []string{consts.DefaultInfoLanguage}
|
|
}
|
|
return languages
|
|
}
|
|
|
|
func validatePurgeMissingOption() error {
|
|
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
|
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
|
if !valid {
|
|
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
|
log.Error(err.Error())
|
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScanSchedule() error {
|
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
|
Server.Scanner.Schedule = ""
|
|
return nil
|
|
}
|
|
var err error
|
|
Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule")
|
|
return err
|
|
}
|
|
|
|
func validateBackupSchedule() error {
|
|
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
|
Server.Backup.Schedule = ""
|
|
return nil
|
|
}
|
|
var err error
|
|
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule")
|
|
return err
|
|
}
|
|
|
|
func validateSchedule(schedule, field string) (string, error) {
|
|
if _, err := time.ParseDuration(schedule); err == nil {
|
|
schedule = "@every " + schedule
|
|
}
|
|
c := cron.New()
|
|
id, err := c.AddFunc(schedule, func() {})
|
|
if err != nil {
|
|
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
|
} else {
|
|
c.Remove(id)
|
|
}
|
|
return schedule, err
|
|
}
|
|
|
|
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
|
// It returns a function that can be used as a hook to validate URLs in the config.
|
|
func validateURL(optionName, optionURL string) func() error {
|
|
return func() error {
|
|
if optionURL == "" {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(optionURL)
|
|
if err != nil {
|
|
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
|
|
return err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
|
log.Error(err.Error())
|
|
return err
|
|
}
|
|
// Require an absolute URL with a non-empty host and no opaque component.
|
|
if u.Host == "" || u.Opaque != "" {
|
|
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
|
log.Error(err.Error())
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func normalizeSearchBackend(value string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
switch v {
|
|
case "fts", "legacy":
|
|
return v
|
|
default:
|
|
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
|
|
return "fts"
|
|
}
|
|
}
|
|
|
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
|
func AddHook(hook func()) {
|
|
hooks = append(hooks, hook)
|
|
}
|
|
|
|
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
|
|
func hasNDEnvVars() bool {
|
|
for _, env := range os.Environ() {
|
|
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setViperDefaults() {
|
|
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
|
viper.SetDefault("cachefolder", "")
|
|
viper.SetDefault("datafolder", ".")
|
|
viper.SetDefault("loglevel", "info")
|
|
viper.SetDefault("logfile", "")
|
|
viper.SetDefault("address", "0.0.0.0")
|
|
viper.SetDefault("port", 4533)
|
|
viper.SetDefault("unixsocketperm", "0660")
|
|
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
|
viper.SetDefault("baseurl", "")
|
|
viper.SetDefault("tlscert", "")
|
|
viper.SetDefault("tlskey", "")
|
|
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
|
|
viper.SetDefault("uiwelcomemessage", "")
|
|
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
|
viper.SetDefault("enabletranscodingconfig", false)
|
|
viper.SetDefault("enabletranscodingcancellation", false)
|
|
viper.SetDefault("transcodingcachesize", "100MB")
|
|
viper.SetDefault("imagecachesize", "100MB")
|
|
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
|
viper.SetDefault("enableartworkprecache", true)
|
|
viper.SetDefault("autoimportplaylists", true)
|
|
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
|
viper.SetDefault("playlistspath", "")
|
|
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
|
viper.SetDefault("enabledownloads", true)
|
|
viper.SetDefault("enableexternalservices", true)
|
|
viper.SetDefault("enablem3uexternalalbumart", false)
|
|
viper.SetDefault("enablemediafilecoverart", true)
|
|
viper.SetDefault("autotranscodedownload", false)
|
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
|
viper.SetDefault("search.fullstring", false)
|
|
viper.SetDefault("search.backend", "fts")
|
|
viper.SetDefault("similarsongsmatchthreshold", 85)
|
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
|
viper.SetDefault("prefersorttags", false)
|
|
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
|
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
|
viper.SetDefault("ffmpegpath", "")
|
|
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
|
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
|
viper.SetDefault("coverjpegquality", 75)
|
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
|
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
|
viper.SetDefault("enablegravatar", false)
|
|
viper.SetDefault("enablefavourites", true)
|
|
viper.SetDefault("enablestarrating", true)
|
|
viper.SetDefault("enableuserediting", true)
|
|
viper.SetDefault("defaulttheme", "Dark")
|
|
viper.SetDefault("defaultlanguage", "")
|
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
|
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
|
viper.SetDefault("enablereplaygain", true)
|
|
viper.SetDefault("enablecoveranimation", true)
|
|
viper.SetDefault("enablenowplaying", true)
|
|
viper.SetDefault("enablecoverartupload", true)
|
|
viper.SetDefault("enablesharing", false)
|
|
viper.SetDefault("shareurl", "")
|
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
|
viper.SetDefault("defaultdownloadableshare", false)
|
|
viper.SetDefault("gatrackingid", "")
|
|
viper.SetDefault("enableinsightscollector", true)
|
|
viper.SetDefault("enablelogredacting", true)
|
|
viper.SetDefault("authrequestlimit", 5)
|
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
|
viper.SetDefault("passwordencryptionkey", "")
|
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
|
viper.SetDefault("extauth.trustedsources", "")
|
|
viper.SetDefault("extauth.logouturl", "")
|
|
viper.SetDefault("prometheus.enabled", false)
|
|
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
|
viper.SetDefault("prometheus.password", "")
|
|
viper.SetDefault("jukebox.enabled", false)
|
|
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
|
viper.SetDefault("jukebox.default", "")
|
|
viper.SetDefault("jukebox.adminonly", true)
|
|
viper.SetDefault("scanner.enabled", true)
|
|
viper.SetDefault("scanner.schedule", "0")
|
|
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
|
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
|
viper.SetDefault("scanner.scanonstartup", true)
|
|
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
|
viper.SetDefault("scanner.genreseparators", "")
|
|
viper.SetDefault("scanner.groupalbumreleases", false)
|
|
viper.SetDefault("scanner.followsymlinks", true)
|
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
|
viper.SetDefault("subsonic.artistparticipations", false)
|
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
|
viper.SetDefault("subsonic.legacyclients", "DSub")
|
|
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
|
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
|
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("spotify.id", "")
|
|
viper.SetDefault("spotify.secret", "")
|
|
viper.SetDefault("deezer.enabled", true)
|
|
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
|
viper.SetDefault("listenbrainz.enabled", true)
|
|
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
|
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
|
viper.SetDefault("enablescrobblehistory", true)
|
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
|
viper.SetDefault("backup.path", "")
|
|
viper.SetDefault("backup.schedule", "")
|
|
viper.SetDefault("backup.count", 0)
|
|
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
|
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
|
viper.SetDefault("inspect.enabled", true)
|
|
viper.SetDefault("inspect.maxrequests", 1)
|
|
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
|
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
|
viper.SetDefault("plugins.folder", "")
|
|
viper.SetDefault("plugins.enabled", true)
|
|
viper.SetDefault("plugins.cachesize", "200MB")
|
|
viper.SetDefault("plugins.autoreload", false)
|
|
|
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
|
viper.SetDefault("devlogsourceline", false)
|
|
viper.SetDefault("devenableprofiler", false)
|
|
viper.SetDefault("devautocreateadminpassword", "")
|
|
viper.SetDefault("devautologinusername", "")
|
|
viper.SetDefault("devactivitypanel", true)
|
|
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
|
viper.SetDefault("devsidebarplaylists", true)
|
|
viper.SetDefault("devshowartistpage", true)
|
|
viper.SetDefault("devuishowconfig", true)
|
|
viper.SetDefault("devneweventstream", true)
|
|
viper.SetDefault("devoffsetoptimize", 50000)
|
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
|
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
|
viper.SetDefault("devexternalscanner", true)
|
|
viper.SetDefault("devscannerthreads", 5)
|
|
viper.SetDefault("devselectivewatcher", true)
|
|
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
|
|
viper.SetDefault("devenableplayerinsights", true)
|
|
viper.SetDefault("devenablepluginsinsights", true)
|
|
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
|
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
|
viper.SetDefault("devoptimizedb", true)
|
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
|
viper.SetDefault("devenablemediafileprobe", true)
|
|
}
|
|
|
|
func init() {
|
|
setViperDefaults()
|
|
}
|
|
|
|
func InitConfig(cfgFile string, loadEnvVars bool) {
|
|
codecRegistry := viper.NewCodecRegistry()
|
|
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
|
|
LoadOptions: ini.LoadOptions{
|
|
UnescapeValueDoubleQuotes: true,
|
|
UnescapeValueCommentSymbols: true,
|
|
},
|
|
})
|
|
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
|
|
|
|
cfgFile = getConfigFile(cfgFile)
|
|
if cfgFile != "" {
|
|
// Use config file from the flag.
|
|
viper.SetConfigFile(cfgFile)
|
|
} else {
|
|
// Search config in local directory with name "navidrome" (without extension).
|
|
viper.AddConfigPath(".")
|
|
viper.SetConfigName("navidrome")
|
|
}
|
|
|
|
_ = viper.BindEnv("port")
|
|
if loadEnvVars {
|
|
viper.SetEnvPrefix("ND")
|
|
replacer := strings.NewReplacer(".", "_")
|
|
viper.SetEnvKeyReplacer(replacer)
|
|
viper.AutomaticEnv()
|
|
}
|
|
|
|
err := viper.ReadInConfig()
|
|
if viper.ConfigFileUsed() != "" && err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// 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 ""
|
|
}
|