diff --git a/model/tag_mappings.go b/model/tag_mappings.go index 3558a1a8c..bfa2e10fd 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -7,6 +7,8 @@ import ( "slices" "strings" "sync" + "unicode" + "unicode/utf8" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -25,12 +27,13 @@ type mappingsConf struct { type tagMappings map[TagName]TagConf type TagConf struct { - Aliases []string `yaml:"aliases"` - Type TagType `yaml:"type"` - MaxLength int `yaml:"maxLength"` - Split []string `yaml:"split"` - Album bool `yaml:"album"` - SplitRx *regexp.Regexp `yaml:"-"` + Aliases []string `yaml:"aliases"` + Type TagType `yaml:"type"` + MaxLength int `yaml:"maxLength"` + Split []string `yaml:"split"` + Album bool `yaml:"album"` + SplitRx *regexp.Regexp `yaml:"-"` + ExceptionsRx *regexp.Regexp `yaml:"-"` } // SplitTagValue splits tag values by the configured split separators. @@ -48,15 +51,84 @@ func (c TagConf) SplitTagValue(values []string) []string { } func (c TagConf) splitValue(tag string) []string { + protected := protectedSpans(tag, c.ExceptionsRx) var parts []string start := 0 for _, sep := range c.SplitRx.FindAllStringIndex(tag, -1) { + if overlapsAny(sep, protected) { + continue + } parts = append(parts, strings.TrimSpace(tag[start:sep[0]])) start = sep[1] } return append(parts, strings.TrimSpace(tag[start:])) } +// protectedSpans returns the spans of rx matches that sit on word boundaries. +// Boundaries are checked here, rune-aware, because RE2's \b is ASCII-only and +// would silently never match names starting/ending with accented letters. +func protectedSpans(tag string, rx *regexp.Regexp) [][]int { + if rx == nil { + return nil + } + var spans [][]int + for _, span := range rx.FindAllStringIndex(tag, -1) { + if isWordBounded(tag, span[0], span[1]) { + spans = append(spans, span) + } + } + return spans +} + +func isWordBounded(s string, start, end int) bool { + isWord := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } + before, _ := utf8.DecodeLastRuneInString(s[:start]) + after, _ := utf8.DecodeRuneInString(s[end:]) + return !isWord(before) && !isWord(after) +} + +func overlapsAny(span []int, spans [][]int) bool { + for _, s := range spans { + if span[0] < s[1] && s[0] < span[1] { + return true + } + } + return false +} + +// compileExceptionsRegex builds a case-insensitive regex matching any of the +// given literal names, or nil if there are none. +func compileExceptionsRegex(exceptions []string) *regexp.Regexp { + var names []string + for _, e := range exceptions { + if e = strings.TrimSpace(e); e != "" { + names = append(names, e) + } + } + if len(names) == 0 { + return nil + } + // Longest-first: Go regex alternation is leftmost-first, so with overlapping + // entries (e.g. "Iron and Wine Duo" vs "Iron and Wine") the longer name must + // come first to win. Ties broken lexicographically for determinism. + slices.SortFunc(names, func(a, b string) int { + if c := cmp.Compare(len(b), len(a)); c != 0 { + return c + } + return cmp.Compare(a, b) + }) + escaped := make([]string, len(names)) + for i, name := range names { + escaped[i] = regexp.QuoteMeta(name) + } + rx, err := regexp.Compile("(?i)(" + strings.Join(escaped, "|") + ")") + if err != nil { + log.Warn("Error compiling split exceptions regexp", "exceptions", exceptions, err) + return nil + } + return rx +} + type TagType string const ( diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index 1665d557b..89287397d 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -60,5 +60,85 @@ var _ = Describe("TagConf", func() { // filterDuplicatedOrEmptyValues in the metadata pipeline. Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) }) + + Context("with split exceptions", func() { + BeforeEach(func() { + conf = TagConf{Split: []string{" and ", ";", "/"}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + conf.ExceptionsRx = compileExceptionsRegex([]string{ + "Iron and Wine", + "Iron and Wine Duo", + "Ella and Louis", + "AC/DC", + "Ólafur Arnalds and Nils Frahm", + }) + }) + + It("does not split a value that is exactly an exception", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine"})).To(Equal([]string{"Iron and Wine"})) + }) + + It("protects an exception embedded in a multi-artist value", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine and Bob"})). + To(Equal([]string{"Iron and Wine", "Bob"})) + }) + + It("protects every occurrence, not just the first", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine; Bob; Iron and Wine"})). + To(Equal([]string{"Iron and Wine", "Bob", "Iron and Wine"})) + }) + + It("matches exceptions case-insensitively and keeps the tag's casing", func() { + Expect(conf.SplitTagValue([]string{"IRON AND WINE and Bob"})). + To(Equal([]string{"IRON AND WINE", "Bob"})) + }) + + It("prefers the longest exception when entries overlap", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine Duo and Bob"})). + To(Equal([]string{"Iron and Wine Duo", "Bob"})) + }) + + It("protects exceptions containing separator characters", func() { + Expect(conf.SplitTagValue([]string{"AC/DC/Queen"})). + To(Equal([]string{"AC/DC", "Queen"})) + }) + + It("does not protect an exception embedded in a longer word", func() { + // "Ella and Louis" must not match inside "Ella and Louise" + Expect(conf.SplitTagValue([]string{"Ella and Louise"})). + To(Equal([]string{"Ella", "Louise"})) + }) + + It("handles names with non-ASCII edges", func() { + Expect(conf.SplitTagValue([]string{"Ólafur Arnalds and Nils Frahm and Bob"})). + To(Equal([]string{"Ólafur Arnalds and Nils Frahm", "Bob"})) + }) + + It("splits normally when no exception matches", func() { + Expect(conf.SplitTagValue([]string{"Foo and Bar"})).To(Equal([]string{"Foo", "Bar"})) + }) + }) + }) + + Describe("compileExceptionsRegex", func() { + It("returns nil for an empty list", func() { + Expect(compileExceptionsRegex(nil)).To(BeNil()) + Expect(compileExceptionsRegex([]string{})).To(BeNil()) + }) + + It("returns nil when all entries are blank", func() { + Expect(compileExceptionsRegex([]string{"", " "})).To(BeNil()) + }) + + It("escapes regex metacharacters in names", func() { + rx := compileExceptionsRegex([]string{"Sigur (Rós)"}) + Expect(rx.FindString("Sigur (Rós)")).To(Equal("Sigur (Rós)")) + Expect(rx.MatchString("Sigur xRósx")).To(BeFalse()) + }) + + It("matches case-insensitively", func() { + rx := compileExceptionsRegex([]string{"Iron and Wine"}) + Expect(rx.MatchString("IRON AND WINE")).To(BeTrue()) + }) }) })