From 3b41bab94ce7ada2543993034424629252077acb Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 2 Jul 2026 00:34:50 -0400 Subject: [PATCH] refactor(scanner): consolidate split-exception wiring and drop hot-path lock ArtistSplitExceptionsRx is called per tag mapping per scanned file across concurrent goroutines; replace the mutex+joined-key cache with an atomic pointer compared via slices.Equal. Route all participant call sites through WithParticipantExceptions and a shared splitParticipantValues helper. --- model/metadata/map_participants.go | 34 ++++++++++++++---------------- model/tag_mappings.go | 25 +++++++++++----------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/model/metadata/map_participants.go b/model/metadata/map_participants.go index d8ebfc55d..35f112a92 100644 --- a/model/metadata/map_participants.go +++ b/model/metadata/map_participants.go @@ -94,15 +94,11 @@ func (md Metadata) processPerformers(participants model.Participants, rolesMbzId roleIdx[role] = 0 } - conf := model.TagRolesConf() - conf.ExceptionsRx = model.ArtistSplitExceptionsRx() + conf := model.TagRolesConf().WithParticipantExceptions(model.TagPerformer) titleCaser := cases.Title(language.Und) for _, performer := range md.Pairs(model.TagPerformer) { subRole := titleCaser.String(performer.Key()) - names := []string{performer.Value()} - if len(conf.Split) > 0 { - names = filterDuplicatedOrEmptyValues(conf.SplitTagValue(names)) - } + names := splitParticipantValues(conf, []string{performer.Value()}) for _, name := range names { artist := model.Artist{ ID: md.artistID(name), @@ -177,6 +173,16 @@ func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist { return artists } +// splitParticipantValues splits values by the conf separators, dropping +// duplicated or empty entries. Values are returned unchanged when the conf +// has no separators. +func splitParticipantValues(conf model.TagConf, values []string) []string { + if len(conf.Split) == 0 { + return values + } + return filterDuplicatedOrEmptyValues(conf.SplitTagValue(values)) +} + // getRoleValues returns the values of a role tag, splitting them if necessary func (md Metadata) getRoleValues(role model.TagName) []string { values := md.Strings(role) @@ -187,12 +193,8 @@ func (md Metadata) getRoleValues(role model.TagName) []string { if conf.Split == nil { conf = model.TagRolesConf() } - if len(conf.Split) > 0 { - conf.ExceptionsRx = model.ArtistSplitExceptionsRx() - values = conf.SplitTagValue(values) - return filterDuplicatedOrEmptyValues(values) - } - return values + conf = conf.WithParticipantExceptions(role) + return splitParticipantValues(conf, values) } // getArtistValues returns the values of a single or multi artist tag, splitting them if necessary @@ -209,12 +211,8 @@ func (md Metadata) getArtistValues(single, multi model.TagName) []string { if conf.Split == nil { conf = model.TagArtistsConf() } - if len(conf.Split) > 0 { - conf.ExceptionsRx = model.ArtistSplitExceptionsRx() - vSingle = conf.SplitTagValue(vSingle) - return filterDuplicatedOrEmptyValues(vSingle) - } - return vSingle + conf = conf.WithParticipantExceptions(single) + return splitParticipantValues(conf, vSingle) } func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string { diff --git a/model/tag_mappings.go b/model/tag_mappings.go index 9f7bb915f..8e8bbc5bd 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "unicode" "unicode/utf8" @@ -129,25 +130,25 @@ func compileExceptionsRegex(exceptions []string) *regexp.Regexp { return rx } -var artistSplitExceptions struct { - sync.Mutex - key string - rx *regexp.Regexp +type artistSplitExceptionsCache struct { + names []string + rx *regexp.Regexp } +var artistSplitExceptions atomic.Pointer[artistSplitExceptionsCache] + // 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. +// configured list changes. Lock-free on the cache-hit path, as this is called +// per tag mapping per scanned file, across concurrent scanner goroutines. 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) + names := conf.Server.Scanner.ArtistSplitExceptions + if c := artistSplitExceptions.Load(); c != nil && slices.Equal(c.names, names) { + return c.rx } + c := &artistSplitExceptionsCache{names: slices.Clone(names), rx: compileExceptionsRegex(names)} + artistSplitExceptions.Store(c) return c.rx }