From 2b0fc5762ae003dcf338ebfb7edc727f7e3fdfcf Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 1 Jul 2026 21:39:27 -0400 Subject: [PATCH] feat(scanner): add Scanner.ArtistSplitExceptions config option --- conf/configuration.go | 24 +++++++++++++----------- model/tag_mappings.go | 22 ++++++++++++++++++++++ model/tag_mappings_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 665a7992f..17abfe400 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -153,17 +153,18 @@ type configOptions struct { } 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 - IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning - PurgeMissing string // Values: "never", "always", "full" + Enabled bool + Schedule string + WatcherWait time.Duration + ScanOnStartup bool + Extractor string + ArtistJoiner string + ArtistSplitExceptions []string // Artist names never split by tag separators + GenreSeparators string // Deprecated: Use Tags.genre.Split instead + GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories + IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning + PurgeMissing string // Values: "never", "always", "full" } type transcodingOptions struct { @@ -819,6 +820,7 @@ func setViperDefaults() { viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) viper.SetDefault("scanner.scanonstartup", true) viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) + viper.SetDefault("scanner.artistsplitexceptions", []string{}) viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) diff --git a/model/tag_mappings.go b/model/tag_mappings.go index bfa2e10fd..7f293edbe 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -129,6 +129,28 @@ func compileExceptionsRegex(exceptions []string) *regexp.Regexp { return rx } +var artistSplitExceptions struct { + sync.Mutex + key string + rx *regexp.Regexp +} + +// ArtistSplitExceptionsRx returns the regex for Scanner.ArtistSplitExceptions, +// or nil if none are configured. Compiled lazily (config hooks only run once +// per process, before tests can override the option) and cached until the +// configured list changes. +func ArtistSplitExceptionsRx() *regexp.Regexp { + c := &artistSplitExceptions + c.Lock() + defer c.Unlock() + key := strings.Join(conf.Server.Scanner.ArtistSplitExceptions, "\x00") + if c.key != key { + c.key = key + c.rx = compileExceptionsRegex(conf.Server.Scanner.ArtistSplitExceptions) + } + return c.rx +} + type TagType string const ( diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index 89287397d..2d66ba48d 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -1,6 +1,8 @@ package model import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -141,4 +143,33 @@ var _ = Describe("TagConf", func() { Expect(rx.MatchString("IRON AND WINE")).To(BeTrue()) }) }) + + Describe("ArtistSplitExceptionsRx", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns nil when no exceptions are configured", func() { + conf.Server.Scanner.ArtistSplitExceptions = nil + Expect(ArtistSplitExceptionsRx()).To(BeNil()) + }) + + It("compiles the configured exceptions", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + rx := ArtistSplitExceptionsRx() + Expect(rx).ToNot(BeNil()) + Expect(rx.MatchString("iron and wine")).To(BeTrue()) + }) + + It("caches the compiled regex until the configuration changes", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + first := ArtistSplitExceptionsRx() + Expect(ArtistSplitExceptionsRx()).To(BeIdenticalTo(first)) + + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + second := ArtistSplitExceptionsRx() + Expect(second).ToNot(BeIdenticalTo(first)) + Expect(second.MatchString("AC/DC")).To(BeTrue()) + }) + }) })