feat(scanner): protect whitelisted names in tag value splitting

Separator matches inside word-bounded exception matches no longer split.
Matching is case-insensitive and longest-first; boundaries are rune-aware.
This commit is contained in:
Deluan 2026-07-01 21:33:41 -04:00
parent 84e5bd3047
commit 10de3b9bc5
2 changed files with 158 additions and 6 deletions

View File

@ -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 (

View File

@ -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())
})
})
})