diff --git a/pkg/manager/checksum.go b/pkg/manager/checksum.go index a545008b6..bc41ddfe1 100644 --- a/pkg/manager/checksum.go +++ b/pkg/manager/checksum.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/spf13/viper" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" @@ -26,16 +25,12 @@ func setInitialMD5Config(txnManager models.TransactionManager) { usingMD5 := count != 0 defaultAlgorithm := models.HashAlgorithmOshash - if usingMD5 { defaultAlgorithm = models.HashAlgorithmMd5 } - // TODO - this should use the config instance - viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm) - viper.SetDefault(config.CalculateMD5, usingMD5) - config := config.GetInstance() + config.SetChecksumDefaultValues(defaultAlgorithm, usingMD5) if err := config.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 4a62c7cbc..eb1db30dc 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -9,6 +9,9 @@ import ( "runtime" "strings" + "sync" + //"github.com/sasha-s/go-deadlock" // if you have deadlock issues + "golang.org/x/crypto/bcrypt" "github.com/spf13/viper" @@ -165,6 +168,8 @@ func HasTLSConfig() bool { type Instance struct { cpuProfilePath string isNewSystem bool + sync.RWMutex + //deadlock.RWMutex // for deadlock testing/issues } var instance *Instance @@ -181,6 +186,8 @@ func (i *Instance) IsNewSystem() bool { } func (i *Instance) SetConfigFile(fn string) { + i.Lock() + defer i.Unlock() viper.SetConfigFile(fn) } @@ -192,6 +199,8 @@ func (i *Instance) GetCPUProfilePath() string { } func (i *Instance) Set(key string, value interface{}) { + i.Lock() + defer i.Unlock() viper.Set(key, value) } @@ -205,11 +214,15 @@ func (i *Instance) SetPassword(value string) { } func (i *Instance) Write() error { + i.Lock() + defer i.Unlock() return viper.WriteConfig() } // GetConfigFile returns the full path to the used configuration file. func (i *Instance) GetConfigFile() string { + i.RLock() + defer i.RUnlock() return viper.ConfigFileUsed() } @@ -226,6 +239,8 @@ func (i *Instance) GetDefaultDatabaseFilePath() string { } func (i *Instance) GetStashPaths() []*models.StashConfig { + i.RLock() + defer i.RUnlock() var ret []*models.StashConfig if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 { // fallback to legacy format @@ -243,30 +258,44 @@ func (i *Instance) GetStashPaths() []*models.StashConfig { } func (i *Instance) GetConfigFilePath() string { + i.RLock() + defer i.RUnlock() return viper.ConfigFileUsed() } func (i *Instance) GetCachePath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Cache) } func (i *Instance) GetGeneratedPath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Generated) } func (i *Instance) GetMetadataPath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Metadata) } func (i *Instance) GetDatabasePath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Database) } func (i *Instance) GetJWTSignKey() []byte { + i.RLock() + defer i.RUnlock() return []byte(viper.GetString(JWTSignKey)) } func (i *Instance) GetSessionStoreKey() []byte { + i.RLock() + defer i.RUnlock() return []byte(viper.GetString(SessionStoreKey)) } @@ -279,14 +308,20 @@ func (i *Instance) GetDefaultScrapersPath() string { } func (i *Instance) GetExcludes() []string { + i.RLock() + defer i.RUnlock() return viper.GetStringSlice(Exclude) } func (i *Instance) GetImageExcludes() []string { + i.RLock() + defer i.RUnlock() return viper.GetStringSlice(ImageExclude) } func (i *Instance) GetVideoExtensions() []string { + i.RLock() + defer i.RUnlock() ret := viper.GetStringSlice(VideoExtensions) if ret == nil { ret = defaultVideoExtensions @@ -295,6 +330,8 @@ func (i *Instance) GetVideoExtensions() []string { } func (i *Instance) GetImageExtensions() []string { + i.RLock() + defer i.RUnlock() ret := viper.GetStringSlice(ImageExtensions) if ret == nil { ret = defaultImageExtensions @@ -303,6 +340,8 @@ func (i *Instance) GetImageExtensions() []string { } func (i *Instance) GetGalleryExtensions() []string { + i.RLock() + defer i.RUnlock() ret := viper.GetStringSlice(GalleryExtensions) if ret == nil { ret = defaultGalleryExtensions @@ -311,10 +350,14 @@ func (i *Instance) GetGalleryExtensions() []string { } func (i *Instance) GetCreateGalleriesFromFolders() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(CreateGalleriesFromFolders) } func (i *Instance) GetLanguage() string { + i.RLock() + defer i.RUnlock() ret := viper.GetString(Language) // default to English @@ -328,12 +371,16 @@ func (i *Instance) GetLanguage() string { // IsCalculateMD5 returns true if MD5 checksums should be generated for // scene video files. func (i *Instance) IsCalculateMD5() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(CalculateMD5) } // GetVideoFileNamingAlgorithm returns what hash algorithm should be used for // naming generated scene video files. func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { + i.RLock() + defer i.RUnlock() ret := viper.GetString(VideoFileNamingAlgorithm) // default to oshash @@ -345,22 +392,30 @@ func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { } func (i *Instance) GetScrapersPath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(ScrapersPath) } func (i *Instance) GetScraperUserAgent() string { + i.RLock() + defer i.RUnlock() return viper.GetString(ScraperUserAgent) } // GetScraperCDPPath gets the path to the Chrome executable or remote address // to an instance of Chrome. func (i *Instance) GetScraperCDPPath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(ScraperCDPPath) } // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. func (i *Instance) GetScraperCertCheck() bool { + i.RLock() + defer i.RUnlock() ret := true if viper.IsSet(ScraperCertCheck) { ret = viper.GetBool(ScraperCertCheck) @@ -370,6 +425,8 @@ func (i *Instance) GetScraperCertCheck() bool { } func (i *Instance) GetScraperExcludeTagPatterns() []string { + i.RLock() + defer i.RUnlock() var ret []string if viper.IsSet(ScraperExcludeTagPatterns) { ret = viper.GetStringSlice(ScraperExcludeTagPatterns) @@ -379,6 +436,8 @@ func (i *Instance) GetScraperExcludeTagPatterns() []string { } func (i *Instance) GetStashBoxes() []*models.StashBox { + i.RLock() + defer i.RUnlock() var boxes []*models.StashBox viper.UnmarshalKey(StashBoxes, &boxes) return boxes @@ -392,34 +451,48 @@ func (i *Instance) GetDefaultPluginsPath() string { } func (i *Instance) GetPluginsPath() string { + i.RLock() + defer i.RUnlock() return viper.GetString(PluginsPath) } func (i *Instance) GetHost() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Host) } func (i *Instance) GetPort() int { + i.RLock() + defer i.RUnlock() return viper.GetInt(Port) } func (i *Instance) GetExternalHost() string { + i.RLock() + defer i.RUnlock() return viper.GetString(ExternalHost) } // GetPreviewSegmentDuration returns the duration of a single segment in a // scene preview file, in seconds. func (i *Instance) GetPreviewSegmentDuration() float64 { + i.RLock() + defer i.RUnlock() return viper.GetFloat64(PreviewSegmentDuration) } // GetParallelTasks returns the number of parallel tasks that should be started // by scan or generate task. func (i *Instance) GetParallelTasks() int { + i.RLock() + defer i.RUnlock() return viper.GetInt(ParallelTasks) } func (i *Instance) GetParallelTasksWithAutoDetection() int { + i.RLock() + defer i.RUnlock() parallelTasks := viper.GetInt(ParallelTasks) if parallelTasks <= 0 { parallelTasks = (runtime.NumCPU() / 4) + 1 @@ -428,11 +501,15 @@ func (i *Instance) GetParallelTasksWithAutoDetection() int { } func (i *Instance) GetPreviewAudio() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(PreviewAudio) } // GetPreviewSegments returns the amount of segments in a scene preview file. func (i *Instance) GetPreviewSegments() int { + i.RLock() + defer i.RUnlock() return viper.GetInt(PreviewSegments) } @@ -443,6 +520,8 @@ func (i *Instance) GetPreviewSegments() int { // in the preview. If the value is suffixed with a '%' character (for example // '2%'), then it is interpreted as a proportion of the total video duration. func (i *Instance) GetPreviewExcludeStart() string { + i.RLock() + defer i.RUnlock() return viper.GetString(PreviewExcludeStart) } @@ -452,12 +531,16 @@ func (i *Instance) GetPreviewExcludeStart() string { // when generating previews. If the value is suffixed with a '%' character, // then it is interpreted as a proportion of the total video duration. func (i *Instance) GetPreviewExcludeEnd() string { + i.RLock() + defer i.RUnlock() return viper.GetString(PreviewExcludeEnd) } // GetPreviewPreset returns the preset when generating previews. Defaults to // Slow. func (i *Instance) GetPreviewPreset() models.PreviewPreset { + i.RLock() + defer i.RUnlock() ret := viper.GetString(PreviewPreset) // default to slow @@ -469,6 +552,8 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset { } func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { + i.RLock() + defer i.RUnlock() ret := viper.GetString(MaxTranscodeSize) // default to original @@ -480,6 +565,8 @@ func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { } func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { + i.RLock() + defer i.RUnlock() ret := viper.GetString(MaxStreamingTranscodeSize) // default to original @@ -491,19 +578,27 @@ func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum } func (i *Instance) GetAPIKey() string { + i.RLock() + defer i.RUnlock() return viper.GetString(ApiKey) } func (i *Instance) GetUsername() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Username) } func (i *Instance) GetPasswordHash() string { + i.RLock() + defer i.RUnlock() return viper.GetString(Password) } func (i *Instance) GetCredentials() (string, string) { if i.HasCredentials() { + i.RLock() + defer i.RUnlock() return viper.GetString(Username), viper.GetString(Password) } @@ -511,12 +606,14 @@ func (i *Instance) GetCredentials() (string, string) { } func (i *Instance) HasCredentials() bool { + i.RLock() + defer i.RUnlock() if !viper.IsSet(Username) || !viper.IsSet(Password) { return false } - username := i.GetUsername() - pwHash := i.GetPasswordHash() + username := viper.GetString(Username) + pwHash := viper.GetString(Password) return username != "" && pwHash != "" } @@ -565,6 +662,8 @@ func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error { // GetMaxSessionAge gets the maximum age for session cookies, in seconds. // Session cookie expiry times are refreshed every request. func (i *Instance) GetMaxSessionAge() int { + i.Lock() + defer i.Unlock() viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge) return viper.GetInt(MaxSessionAge) } @@ -572,15 +671,21 @@ func (i *Instance) GetMaxSessionAge() int { // GetCustomServedFolders gets the map of custom paths to their applicable // filesystem locations func (i *Instance) GetCustomServedFolders() URLMap { + i.RLock() + defer i.RUnlock() return viper.GetStringMapString(CustomServedFolders) } func (i *Instance) GetCustomUILocation() string { + i.RLock() + defer i.RUnlock() return viper.GetString(CustomUILocation) } // Interface options func (i *Instance) GetMenuItems() []string { + i.RLock() + defer i.RUnlock() if viper.IsSet(MenuItems) { return viper.GetStringSlice(MenuItems) } @@ -588,46 +693,63 @@ func (i *Instance) GetMenuItems() []string { } func (i *Instance) GetSoundOnPreview() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(SoundOnPreview) } func (i *Instance) GetWallShowTitle() bool { + i.Lock() + defer i.Unlock() viper.SetDefault(WallShowTitle, true) return viper.GetBool(WallShowTitle) } func (i *Instance) GetCustomPerformerImageLocation() string { - // don't set the default, as it causes race condition crashes - // viper.SetDefault(CustomPerformerImageLocation, "") + i.Lock() + defer i.Unlock() + viper.SetDefault(CustomPerformerImageLocation, "") return viper.GetString(CustomPerformerImageLocation) } func (i *Instance) GetWallPlayback() string { + i.Lock() + defer i.Unlock() viper.SetDefault(WallPlayback, "video") return viper.GetString(WallPlayback) } func (i *Instance) GetMaximumLoopDuration() int { + i.Lock() + defer i.Unlock() viper.SetDefault(MaximumLoopDuration, 0) return viper.GetInt(MaximumLoopDuration) } func (i *Instance) GetAutostartVideo() bool { + i.Lock() + defer i.Unlock() viper.SetDefault(AutostartVideo, false) return viper.GetBool(AutostartVideo) } func (i *Instance) GetShowStudioAsText() bool { + i.Lock() + defer i.Unlock() viper.SetDefault(ShowStudioAsText, false) return viper.GetBool(ShowStudioAsText) } func (i *Instance) GetSlideshowDelay() int { + i.Lock() + defer i.Unlock() viper.SetDefault(SlideshowDelay, 5000) return viper.GetInt(SlideshowDelay) } func (i *Instance) GetCSSPath() string { + i.RLock() + defer i.RUnlock() // use custom.css in the same directory as the config file configFileUsed := viper.ConfigFileUsed() configDir := filepath.Dir(configFileUsed) @@ -655,6 +777,8 @@ func (i *Instance) GetCSS() string { } func (i *Instance) SetCSS(css string) { + i.RLock() + defer i.RUnlock() fn := i.GetCSSPath() buf := []byte(css) @@ -663,39 +787,53 @@ func (i *Instance) SetCSS(css string) { } func (i *Instance) GetCSSEnabled() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(CSSEnabled) } func (i *Instance) GetHandyKey() string { + i.RLock() + defer i.RUnlock() return viper.GetString(HandyKey) } // GetDLNAServerName returns the visible name of the DLNA server. If empty, // "stash" will be used. func (i *Instance) GetDLNAServerName() string { + i.RLock() + defer i.RUnlock() return viper.GetString(DLNAServerName) } // GetDLNADefaultEnabled returns true if the DLNA is enabled by default. func (i *Instance) GetDLNADefaultEnabled() bool { + i.RLock() + defer i.RUnlock() return viper.GetBool(DLNADefaultEnabled) } // GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that // are allowed to use the DLNA service. func (i *Instance) GetDLNADefaultIPWhitelist() []string { + i.RLock() + defer i.RUnlock() return viper.GetStringSlice(DLNADefaultIPWhitelist) } // GetDLNAInterfaces returns a list of interface names to expose DLNA on. If // empty, runs on all interfaces. func (i *Instance) GetDLNAInterfaces() []string { + i.RLock() + defer i.RUnlock() return viper.GetStringSlice(DLNAInterfaces) } // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func (i *Instance) GetLogFile() string { + i.RLock() + defer i.RUnlock() return viper.GetString(LogFile) } @@ -703,6 +841,8 @@ func (i *Instance) GetLogFile() string { // in addition to writing to a log file. Logging will be output to the // terminal if file logging is disabled. Defaults to true. func (i *Instance) GetLogOut() bool { + i.RLock() + defer i.RUnlock() ret := true if viper.IsSet(LogOut) { ret = viper.GetBool(LogOut) @@ -714,6 +854,8 @@ func (i *Instance) GetLogOut() bool { // GetLogLevel returns the lowest log level to write to the log. // Should be one of "Debug", "Info", "Warning", "Error" func (i *Instance) GetLogLevel() string { + i.RLock() + defer i.RUnlock() const defaultValue = "Info" value := viper.GetString(LogLevel) @@ -727,6 +869,8 @@ func (i *Instance) GetLogLevel() string { // GetLogAccess returns true if http requests should be logged to the terminal. // HTTP requests are not logged to the log file. Defaults to true. func (i *Instance) GetLogAccess() bool { + i.RLock() + defer i.RUnlock() ret := true if viper.IsSet(LogAccess) { ret = viper.GetBool(LogAccess) @@ -737,6 +881,8 @@ func (i *Instance) GetLogAccess() bool { // Max allowed graphql upload size in megabytes func (i *Instance) GetMaxUploadSize() int64 { + i.RLock() + defer i.RUnlock() ret := int64(1024) if viper.IsSet(MaxUploadSize) { ret = viper.GetInt64(MaxUploadSize) @@ -745,6 +891,8 @@ func (i *Instance) GetMaxUploadSize() int64 { } func (i *Instance) Validate() error { + i.RLock() + defer i.RUnlock() mandatoryPaths := []string{ Database, Generated, @@ -767,7 +915,22 @@ func (i *Instance) Validate() error { return nil } +func (i *Instance) SetChecksumDefaultValues(defaultAlgorithm models.HashAlgorithm, usingMD5 bool) { + i.Lock() + defer i.Unlock() + viper.SetDefault(VideoFileNamingAlgorithm, defaultAlgorithm) + viper.SetDefault(CalculateMD5, usingMD5) +} + func (i *Instance) setDefaultValues() error { + + // read data before write lock scope + defaultDatabaseFilePath := i.GetDefaultDatabaseFilePath() + defaultScrapersPath := i.GetDefaultScrapersPath() + defaultPluginsPath := i.GetDefaultPluginsPath() + + i.Lock() + defer i.Unlock() viper.SetDefault(ParallelTasks, parallelTasksDefault) viper.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault) viper.SetDefault(PreviewSegments, previewSegmentsDefault) @@ -776,14 +939,14 @@ func (i *Instance) setDefaultValues() error { viper.SetDefault(PreviewAudio, previewAudioDefault) viper.SetDefault(SoundOnPreview, false) - viper.SetDefault(Database, i.GetDefaultDatabaseFilePath()) + viper.SetDefault(Database, defaultDatabaseFilePath) // Set generated to the metadata path for backwards compat viper.SetDefault(Generated, viper.GetString(Metadata)) // Set default scrapers and plugins paths - viper.SetDefault(ScrapersPath, i.GetDefaultScrapersPath()) - viper.SetDefault(PluginsPath, i.GetDefaultPluginsPath()) + viper.SetDefault(ScrapersPath, defaultScrapersPath) + viper.SetDefault(PluginsPath, defaultPluginsPath) return viper.WriteConfig() } diff --git a/pkg/manager/config/config_concurrency_test.go b/pkg/manager/config/config_concurrency_test.go new file mode 100644 index 000000000..2f86f332e --- /dev/null +++ b/pkg/manager/config/config_concurrency_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "sync" + "testing" +) + +// should be run with -race +func TestConcurrentConfigAccess(t *testing.T) { + i := GetInstance() + + const workers = 8 + //const loops = 1000 + const loops = 200 + var wg sync.WaitGroup + for t := 0; t < workers; t++ { + wg.Add(1) + go func() { + for l := 0; l < loops; l++ { + i.SetInitialConfig() + + i.HasCredentials() + i.GetCPUProfilePath() + i.GetConfigFile() + i.GetConfigPath() + i.GetDefaultDatabaseFilePath() + i.GetStashPaths() + i.GetConfigFilePath() + i.Set(Cache, i.GetCachePath()) + i.Set(Generated, i.GetGeneratedPath()) + i.Set(Metadata, i.GetMetadataPath()) + i.Set(Database, i.GetDatabasePath()) + i.Set(JWTSignKey, i.GetJWTSignKey()) + i.Set(SessionStoreKey, i.GetSessionStoreKey()) + i.GetDefaultScrapersPath() + i.Set(Exclude, i.GetExcludes()) + i.Set(ImageExclude, i.GetImageExcludes()) + i.Set(VideoExtensions, i.GetVideoExtensions()) + i.Set(ImageExtensions, i.GetImageExtensions()) + i.Set(GalleryExtensions, i.GetGalleryExtensions()) + i.Set(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders()) + i.Set(Language, i.GetLanguage()) + i.Set(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm()) + i.Set(ScrapersPath, i.GetScrapersPath()) + i.Set(ScraperUserAgent, i.GetScraperUserAgent()) + i.Set(ScraperCDPPath, i.GetScraperCDPPath()) + i.Set(ScraperCertCheck, i.GetScraperCertCheck()) + i.Set(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns()) + i.Set(StashBoxes, i.GetStashBoxes()) + i.GetDefaultPluginsPath() + i.Set(PluginsPath, i.GetPluginsPath()) + i.Set(Host, i.GetHost()) + i.Set(Port, i.GetPort()) + i.Set(ExternalHost, i.GetExternalHost()) + i.Set(PreviewSegmentDuration, i.GetPreviewSegmentDuration()) + i.Set(ParallelTasks, i.GetParallelTasks()) + i.Set(ParallelTasks, i.GetParallelTasksWithAutoDetection()) + i.Set(PreviewAudio, i.GetPreviewAudio()) + i.Set(PreviewSegments, i.GetPreviewSegments()) + i.Set(PreviewExcludeStart, i.GetPreviewExcludeStart()) + i.Set(PreviewExcludeEnd, i.GetPreviewExcludeEnd()) + i.Set(PreviewPreset, i.GetPreviewPreset()) + i.Set(MaxTranscodeSize, i.GetMaxTranscodeSize()) + i.Set(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize()) + i.Set(ApiKey, i.GetAPIKey()) + i.Set(Username, i.GetUsername()) + i.Set(Password, i.GetPasswordHash()) + i.GetCredentials() + i.Set(MaxSessionAge, i.GetMaxSessionAge()) + i.Set(CustomServedFolders, i.GetCustomServedFolders()) + i.Set(CustomUILocation, i.GetCustomUILocation()) + i.Set(MenuItems, i.GetMenuItems()) + i.Set(SoundOnPreview, i.GetSoundOnPreview()) + i.Set(WallShowTitle, i.GetWallShowTitle()) + i.Set(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation()) + i.Set(WallPlayback, i.GetWallPlayback()) + i.Set(MaximumLoopDuration, i.GetMaximumLoopDuration()) + i.Set(AutostartVideo, i.GetAutostartVideo()) + i.Set(ShowStudioAsText, i.GetShowStudioAsText()) + i.Set(SlideshowDelay, i.GetSlideshowDelay()) + i.GetCSSPath() + i.GetCSS() + i.Set(CSSEnabled, i.GetCSSEnabled()) + i.Set(HandyKey, i.GetHandyKey()) + i.Set(DLNAServerName, i.GetDLNAServerName()) + i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled()) + i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist()) + i.Set(DLNAInterfaces, i.GetDLNAInterfaces()) + i.Set(LogFile, i.GetLogFile()) + i.Set(LogOut, i.GetLogOut()) + i.Set(LogLevel, i.GetLogLevel()) + i.Set(LogAccess, i.GetLogAccess()) + i.Set(MaxUploadSize, i.GetMaxUploadSize()) + } + wg.Done() + }() + } + + wg.Wait() +} diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md index 7dfbbdcc1..929556c08 100644 --- a/ui/v2.5/src/components/Changelog/versions/v090.md +++ b/ui/v2.5/src/components/Changelog/versions/v090.md @@ -25,6 +25,7 @@ * Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578)) ### 🐛 Bug fixes +* Fix race condition panic when reading and writing config concurrently. ([#1645](https://github.com/stashapp/stash/issues/1343)) * Fix performance issue on Studios page getting studio image count. ([#1643](https://github.com/stashapp/stash/pull/1643)) * Regenerate scene phash if overwrite flag is set. ([#1633](https://github.com/stashapp/stash/pull/1633)) * Create .stash directory in $HOME only if required. ([#1623](https://github.com/stashapp/stash/pull/1623))