mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
84e5bd3047
commit
10de3b9bc5
@ -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 (
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user