navidrome/model/tag_mappings.go
Deluan Quintão 4cbba2ae49
feat(scanner): add ArtistSplitExceptions to protect artist names from splitting (#5701)
* refactor(scanner): make tag value splitting position-based

Replaces the ZWSP substitution trick with index-based cutting, in
preparation for artist split exceptions, which need match positions.

* 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.

* feat(scanner): add Scanner.ArtistSplitExceptions config option

* feat(scanner): honor artist split exceptions for participant tags

Applies Scanner.ArtistSplitExceptions to artist, albumartist and role tag
splitting. Generic tags (genre, mood, ...) are unaffected.

* fix(scanner): apply split exceptions when per-tag Split overrides participant tags

Per-tag Tags.<name>.Split makes the generic ingestion path split the tag
before participant mapping runs, bypassing the whitelist. Attach the
exceptions to participant tag mappings (including sort variants) in clean().

* feat(scanner): split performer names and honor split exceptions

Performer pair values were never split; multiple names in one PERFORMER
value stayed a single artist. Split them with the roles separators, using
the same whitelist protection as other participant tags.

* test(scanner): lock MBID ordering for split performer values

* 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.

* refactor(scanner): unexport artistSplitExceptionsRx

All external callers go through WithParticipantExceptions, so the accessor
does not need to be part of the model package API.
2026-07-02 08:29:44 -04:00

373 lines
10 KiB
Go

package model
import (
"cmp"
"maps"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
"unicode"
"unicode/utf8"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/resources"
"gopkg.in/yaml.v3"
)
type mappingsConf struct {
Main tagMappings `yaml:"main"`
Additional tagMappings `yaml:"additional"`
Roles TagConf `yaml:"roles"`
Artists TagConf `yaml:"artists"`
}
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:"-"`
ExceptionsRx *regexp.Regexp `yaml:"-"`
}
// SplitTagValue splits tag values by the configured split separators.
// Each value in the input slice is individually split and trimmed.
func (c TagConf) SplitTagValue(values []string) []string {
if c.SplitRx == nil || len(values) == 0 {
return values
}
var result []string
for _, tag := range values {
result = append(result, c.splitValue(tag)...)
}
return result
}
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 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. 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 {
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
}
// participantTagNames are the tags that hold artist names (or their sort
// values), where split exceptions apply.
var participantTagNames = sync.OnceValue(func() map[TagName]struct{} {
names := []TagName{
TagTrackArtist, TagTrackArtists, TagTrackArtistSort, TagTrackArtistsSort,
TagAlbumArtist, TagAlbumArtists, TagAlbumArtistSort, TagAlbumArtistsSort,
}
set := make(map[TagName]struct{}, len(names)+2*len(AllRoles))
for _, n := range names {
set[n] = struct{}{}
}
for role := range AllRoles {
set[TagName(role)] = struct{}{}
set[TagName(role+"sort")] = struct{}{}
}
return set
})
// WithParticipantExceptions returns the conf with the global artist split
// exceptions attached when name is a participant (artist/role) tag.
func (c TagConf) WithParticipantExceptions(name TagName) TagConf {
if _, ok := participantTagNames()[name]; ok {
c.ExceptionsRx = artistSplitExceptionsRx()
}
return c
}
type TagType string
const (
TagTypeString TagType = "string"
TagTypeInteger TagType = "int"
TagTypeFloat TagType = "float"
TagTypeDate TagType = "date"
TagTypeUUID TagType = "uuid"
TagTypePair TagType = "pair"
)
func TagMappings() map[TagName]TagConf {
mappings, _ := parseMappings()
return mappings
}
func TagRolesConf() TagConf {
_, cfg := parseMappings()
return cfg.Roles
}
func TagArtistsConf() TagConf {
_, cfg := parseMappings()
return cfg.Artists
}
func TagMainMappings() map[TagName]TagConf {
_, mappings := parseMappings()
return mappings.Main
}
var _mappings mappingsConf
var parseMappings = sync.OnceValues(func() (map[TagName]TagConf, mappingsConf) {
_mappings.Artists.SplitRx = compileSplitRegex("artists", _mappings.Artists.Split)
_mappings.Roles.SplitRx = compileSplitRegex("roles", _mappings.Roles.Split)
normalized := tagMappings{}
collectTags(_mappings.Main, normalized)
_mappings.Main = normalized
normalized = tagMappings{}
collectTags(_mappings.Additional, normalized)
_mappings.Additional = normalized
// Merge main and additional mappings, log an error if a tag is found in both
for k, v := range _mappings.Main {
if _, ok := _mappings.Additional[k]; ok {
log.Error("Tag found in both main and additional mappings", "tag", k)
}
normalized[k] = v
}
return normalized, _mappings
})
func collectTags(tagMappings, normalized map[TagName]TagConf) {
for k, v := range tagMappings {
var aliases []string
for _, val := range v.Aliases {
aliases = append(aliases, strings.ToLower(val))
}
if v.Split != nil {
if v.Type != "" && v.Type != TagTypeString {
log.Error("Tag splitting only available for string types", "tag", k, "split", v.Split,
"type", string(v.Type))
v.Split = nil
} else {
v.SplitRx = compileSplitRegex(k, v.Split)
}
}
v.Aliases = aliases
normalized[k.ToLower()] = v
}
}
func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp {
// Build a list of escaped, non-empty separators.
var escaped []string
for _, s := range split {
if s == "" {
continue
}
escaped = append(escaped, regexp.QuoteMeta(s))
}
// If no valid separators remain, return the original value.
if len(escaped) == 0 {
if len(split) > 0 {
log.Warn("No valid separators found in split list", "split", split, "tag", tagName)
}
return nil
}
// Create one regex that matches any of the separators (case-insensitive).
pattern := "(?i)(" + strings.Join(escaped, "|") + ")"
re, err := regexp.Compile(pattern)
if err != nil {
log.Warn("Error compiling regexp for split list", "pattern", pattern, "tag", tagName, "split", split, err)
return nil
}
return re
}
func tagNames() []string {
mappings := TagMappings()
names := make([]string, 0, len(mappings))
for k := range mappings {
names = append(names, string(k))
}
return names
}
func numericTagNames() []string {
mappings := TagMappings()
names := make([]string, 0)
for k, cfg := range mappings {
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
names = append(names, string(k))
}
}
return names
}
func loadTagMappings() {
mappingsFile, err := resources.FS().Open("mappings.yaml")
if err != nil {
log.Error("Error opening mappings.yaml", err)
}
decoder := yaml.NewDecoder(mappingsFile)
err = decoder.Decode(&_mappings)
if err != nil {
log.Error("Error decoding mappings.yaml", err)
}
if len(_mappings.Main) == 0 {
log.Error("No tag mappings found in mappings.yaml, check the format")
}
// Use Scanner.GenreSeparators if specified and Tags.genre is not defined
if conf.Server.Scanner.GenreSeparators != "" && len(conf.Server.Tags["genre"].Aliases) == 0 {
genreConf := _mappings.Main[TagName("genre")]
genreConf.Split = strings.Split(conf.Server.Scanner.GenreSeparators, "")
genreConf.SplitRx = compileSplitRegex("genre", genreConf.Split)
_mappings.Main[TagName("genre")] = genreConf
log.Debug("Loading deprecated list of genre separators", "separators", genreConf.Split)
}
// Overwrite the default mappings with the ones from the config
for tag, cfg := range conf.Server.Tags {
if cfg.Ignore {
delete(_mappings.Main, TagName(tag))
delete(_mappings.Additional, TagName(tag))
continue
}
oldValue, ok := _mappings.Main[TagName(tag)]
if !ok {
oldValue = _mappings.Additional[TagName(tag)]
}
aliases := cfg.Aliases
if len(aliases) == 0 {
aliases = oldValue.Aliases
}
split := cfg.Split
if split == nil {
split = oldValue.Split
}
c := TagConf{
Aliases: aliases,
Split: split,
Type: cmp.Or(TagType(cfg.Type), oldValue.Type),
MaxLength: cmp.Or(cfg.MaxLength, oldValue.MaxLength),
Album: cmp.Or(cfg.Album, oldValue.Album),
}
c.SplitRx = compileSplitRegex(TagName(tag), c.Split)
if _, ok := _mappings.Main[TagName(tag)]; ok {
_mappings.Main[TagName(tag)] = c
} else {
_mappings.Additional[TagName(tag)] = c
}
}
}
func init() {
conf.AddHook(func() {
loadTagMappings()
// This is here to avoid cyclic imports. The criteria package needs to know all tag names, so they can be
// used in smart playlists
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
criteria.AddTagNames(tagNames())
criteria.AddNumericTags(numericTagNames())
})
}