mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* 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.
243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
package metadata
|
|
|
|
import (
|
|
"cmp"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
type roleTags struct {
|
|
name model.TagName
|
|
sort model.TagName
|
|
mbid model.TagName
|
|
}
|
|
|
|
var roleMappings = map[model.Role]roleTags{
|
|
model.RoleComposer: {name: model.TagComposer, sort: model.TagComposerSort, mbid: model.TagMusicBrainzComposerID},
|
|
model.RoleLyricist: {name: model.TagLyricist, sort: model.TagLyricistSort, mbid: model.TagMusicBrainzLyricistID},
|
|
model.RoleConductor: {name: model.TagConductor, mbid: model.TagMusicBrainzConductorID},
|
|
model.RoleArranger: {name: model.TagArranger, mbid: model.TagMusicBrainzArrangerID},
|
|
model.RoleDirector: {name: model.TagDirector, mbid: model.TagMusicBrainzDirectorID},
|
|
model.RoleProducer: {name: model.TagProducer, mbid: model.TagMusicBrainzProducerID},
|
|
model.RoleEngineer: {name: model.TagEngineer, mbid: model.TagMusicBrainzEngineerID},
|
|
model.RoleMixer: {name: model.TagMixer, mbid: model.TagMusicBrainzMixerID},
|
|
model.RoleRemixer: {name: model.TagRemixer, mbid: model.TagMusicBrainzRemixerID},
|
|
model.RoleDJMixer: {name: model.TagDJMixer, mbid: model.TagMusicBrainzDJMixerID},
|
|
}
|
|
|
|
func (md Metadata) mapParticipants() model.Participants {
|
|
participants := make(model.Participants)
|
|
|
|
// Parse track artists
|
|
artists := md.parseArtists(
|
|
model.TagTrackArtist, model.TagTrackArtists,
|
|
model.TagTrackArtistSort, model.TagTrackArtistsSort,
|
|
model.TagMusicBrainzArtistID,
|
|
)
|
|
participants.Add(model.RoleArtist, artists...)
|
|
|
|
// Parse album artists
|
|
albumArtists := md.parseArtists(
|
|
model.TagAlbumArtist, model.TagAlbumArtists,
|
|
model.TagAlbumArtistSort, model.TagAlbumArtistsSort,
|
|
model.TagMusicBrainzAlbumArtistID,
|
|
)
|
|
if len(albumArtists) == 1 && albumArtists[0].Name == consts.UnknownArtist {
|
|
if md.Bool(model.TagCompilation) {
|
|
albumArtists = md.buildArtists([]string{consts.VariousArtists}, nil, []string{consts.VariousArtistsMbzId})
|
|
} else {
|
|
albumArtists = artists
|
|
}
|
|
}
|
|
participants.Add(model.RoleAlbumArtist, albumArtists...)
|
|
|
|
// Parse all other roles
|
|
for role, info := range roleMappings {
|
|
names := md.getRoleValues(info.name)
|
|
if len(names) > 0 {
|
|
sorts := md.Strings(info.sort)
|
|
mbids := md.Strings(info.mbid)
|
|
artists := md.buildArtists(names, sorts, mbids)
|
|
participants.Add(role, artists...)
|
|
}
|
|
}
|
|
|
|
rolesMbzIdMap := md.buildRoleMbidMaps()
|
|
md.processPerformers(participants, rolesMbzIdMap)
|
|
md.syncMissingMbzIDs(participants)
|
|
|
|
return participants
|
|
}
|
|
|
|
// buildRoleMbidMaps creates a map of roles to MBZ IDs
|
|
func (md Metadata) buildRoleMbidMaps() map[string][]string {
|
|
titleCaser := cases.Title(language.Und)
|
|
rolesMbzIdMap := make(map[string][]string)
|
|
for _, mbid := range md.Pairs(model.TagMusicBrainzPerformerID) {
|
|
role := titleCaser.String(mbid.Key())
|
|
rolesMbzIdMap[role] = append(rolesMbzIdMap[role], mbid.Value())
|
|
}
|
|
|
|
return rolesMbzIdMap
|
|
}
|
|
|
|
func (md Metadata) processPerformers(participants model.Participants, rolesMbzIdMap map[string][]string) {
|
|
// roleIdx keeps track of the index of the MBZ ID for each role
|
|
roleIdx := make(map[string]int)
|
|
for role := range rolesMbzIdMap {
|
|
roleIdx[role] = 0
|
|
}
|
|
|
|
conf := model.TagRolesConf().WithParticipantExceptions(model.TagPerformer)
|
|
titleCaser := cases.Title(language.Und)
|
|
for _, performer := range md.Pairs(model.TagPerformer) {
|
|
subRole := titleCaser.String(performer.Key())
|
|
names := splitParticipantValues(conf, []string{performer.Value()})
|
|
for _, name := range names {
|
|
artist := model.Artist{
|
|
ID: md.artistID(name),
|
|
Name: name,
|
|
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
|
MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx),
|
|
}
|
|
participants.AddWithSubRole(model.RolePerformer, subRole, artist)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getPerformerMbid returns the MBZ ID for a performer, based on the subrole
|
|
func (md Metadata) getPerformerMbid(subRole string, rolesMbzIdMap map[string][]string, roleIdx map[string]int) string {
|
|
if mbids, exists := rolesMbzIdMap[subRole]; exists && roleIdx[subRole] < len(mbids) {
|
|
defer func() { roleIdx[subRole]++ }()
|
|
return mbids[roleIdx[subRole]]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// syncMissingMbzIDs fills in missing MBZ IDs for artists that have been previously parsed
|
|
func (md Metadata) syncMissingMbzIDs(participants model.Participants) {
|
|
artistMbzIDMap := make(map[string]string)
|
|
for _, artist := range append(participants[model.RoleArtist], participants[model.RoleAlbumArtist]...) {
|
|
if artist.MbzArtistID != "" {
|
|
artistMbzIDMap[artist.Name] = artist.MbzArtistID
|
|
}
|
|
}
|
|
|
|
for role, list := range participants {
|
|
for i, artist := range list {
|
|
if artist.MbzArtistID == "" {
|
|
if mbzID, exists := artistMbzIDMap[artist.Name]; exists {
|
|
participants[role][i].MbzArtistID = mbzID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (md Metadata) parseArtists(
|
|
name model.TagName, names model.TagName, sort model.TagName,
|
|
sorts model.TagName, mbid model.TagName,
|
|
) []model.Artist {
|
|
nameValues := md.getArtistValues(name, names)
|
|
sortValues := md.getArtistValues(sort, sorts)
|
|
mbids := md.Strings(mbid)
|
|
if len(nameValues) == 0 {
|
|
nameValues = []string{consts.UnknownArtist}
|
|
}
|
|
return md.buildArtists(nameValues, sortValues, mbids)
|
|
}
|
|
|
|
func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist {
|
|
var artists []model.Artist
|
|
for i, name := range names {
|
|
id := md.artistID(name)
|
|
artist := model.Artist{
|
|
ID: id,
|
|
Name: name,
|
|
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
|
}
|
|
if i < len(sorts) {
|
|
artist.SortArtistName = sorts[i]
|
|
}
|
|
if i < len(mbids) {
|
|
artist.MbzArtistID = mbids[i]
|
|
}
|
|
artists = append(artists, 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)
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
conf := model.TagMainMappings()[role]
|
|
if conf.Split == nil {
|
|
conf = model.TagRolesConf()
|
|
}
|
|
conf = conf.WithParticipantExceptions(role)
|
|
return splitParticipantValues(conf, values)
|
|
}
|
|
|
|
// getArtistValues returns the values of a single or multi artist tag, splitting them if necessary
|
|
func (md Metadata) getArtistValues(single, multi model.TagName) []string {
|
|
vMulti := md.Strings(multi)
|
|
if len(vMulti) > 0 {
|
|
return vMulti
|
|
}
|
|
vSingle := md.Strings(single)
|
|
if len(vSingle) != 1 {
|
|
return vSingle
|
|
}
|
|
conf := model.TagMainMappings()[single]
|
|
if conf.Split == nil {
|
|
conf = model.TagArtistsConf()
|
|
}
|
|
conf = conf.WithParticipantExceptions(single)
|
|
return splitParticipantValues(conf, vSingle)
|
|
}
|
|
|
|
func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string {
|
|
return cmp.Or(
|
|
strings.Join(md.tags[singularTagName], conf.Server.Scanner.ArtistJoiner),
|
|
strings.Join(md.tags[pluralTagName], conf.Server.Scanner.ArtistJoiner),
|
|
)
|
|
}
|
|
|
|
func (md Metadata) mapDisplayArtist() string {
|
|
return cmp.Or(
|
|
md.mapDisplayName(model.TagTrackArtist, model.TagTrackArtists),
|
|
consts.UnknownArtist,
|
|
)
|
|
}
|
|
|
|
func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string {
|
|
fallbackName := consts.UnknownArtist
|
|
if md.Bool(model.TagCompilation) {
|
|
fallbackName = consts.VariousArtists
|
|
}
|
|
return cmp.Or(
|
|
md.mapDisplayName(model.TagAlbumArtist, model.TagAlbumArtists),
|
|
mf.Participants.First(model.RoleAlbumArtist).Name,
|
|
fallbackName,
|
|
)
|
|
}
|