d004f99f8f
* feat(playlist): add custom playlist cover art upload - #406 Allow users to upload, view, and remove custom cover images for playlists. Custom images take priority over the auto-generated tiled artwork. Backend: - Add `image_path` column to playlist table (migration with proper rollback) - Add `SetImage`/`RemoveImage` methods to playlist service - Add `POST/DELETE /api/playlist/{id}/image` endpoints - Prioritize custom image in artwork reader pipeline - Clean up image files on playlist deletion - Use glob-based cleanup to prevent orphaned files across format changes - Reject uploads with undetermined image type (400) Frontend: - Hover overlay on playlist cover with upload (camera) and remove (trash) buttons - Lightbox for full-size cover art viewing - Cover art thumbnails in the playlist list view - Loading/error states and i18n strings Closes #406 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor: rename playlist image path migration file Signed-off-by: Deluan <deluan@navidrome.org> * fix(playlist): address review feedback for cover art upload - #406 - Use httpClient instead of raw fetch for image upload/remove - Revert glob cleanup to simple imagePath check - Add log.Error before all error HTTP responses - Add backend tests for SetImage and RemoveImage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor(playlist): use Playlist.ArtworkPath() for image storage Migrate all playlist image path handling to use the new Playlist.ArtworkPath() method as the single source of truth. The DB now stores only the filename (e.g. "pls-1.jpg") instead of a relative path, and images are stored under {DataFolder}/artwork/playlist/ instead of {DataFolder}/playlist_images/. The artwork root directory is created at startup alongside DataFolder and CacheFolder. This also removes the conf dependency from reader_playlist.go since path resolution is now fully encapsulated in the model. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): streamline artwork image selection logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move translation keys, add pt-BR translations Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): rename image_path to image_file Rename the playlist cover art column and field from image_path/ImagePath to image_file/ImageFile across the migration, model, service, tests, and UI. The new name more accurately describes what the field stores (a filename, not a path) and aligns with the existing ImageFiles/IsImageFile naming conventions in the codebase. --------- Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
817 lines
28 KiB
Go
817 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
|
|
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
|
|
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
|
|
DevLegacyEmbedImage bool
|
|
DevInsightsInitialDelay time.Duration
|
|
DevEnablePlayerInsights bool
|
|
DevEnablePluginsInsights bool
|
|
DevPluginCompilationTimeout time.Duration
|
|
DevExternalArtistFetchMultiplier float64
|
|
DevOptimizeDB bool
|
|
DevPreserveUnicodeInExternalCalls 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.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("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("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)
|
|
}
|
|
|
|
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 ""
|
|
}
|