navidrome/model/tag.go
Deluan Quintão cb0a6cedd6
fix(scanner): keep album tag order from the files instead of alphabetical (#5872)
Album-level tags were ordered by frequency and then alphabetically by value.
Album.Genre is just the first genre in that list, so any album whose genres tie
on frequency, which is the normal case, displayed the alphabetically first genre
rather than the first one in the file. A file tagged
"Native American New Age; Indigenous American Traditional Music; Ambient"
showed up as "Ambient".

Break frequency ties on order of appearance instead. This affects all
album-level tags, so mood tagged "Happy; Chill" now keeps that order too.
MediaFiles.ToAlbum already sorts the files by path before flattening their tags,
so the aggregated order stays deterministic across scans.

Only album genre was affected; media_file tags already preserved file order.
2026-08-25 10:58:58 -04:00

266 lines
7.1 KiB
Go

package model
import (
"cmp"
"fmt"
"slices"
"strings"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
type Tag struct {
ID string `json:"id,omitempty"`
TagName TagName `json:"tagName,omitempty"`
TagValue string `json:"tagValue,omitempty"`
AlbumCount int `json:"albumCount,omitempty"`
SongCount int `json:"songCount,omitempty"`
}
type TagList []Tag
func (l TagList) GroupByFrequency() Tags {
grouped := map[string]map[string]int{}
values := map[string]string{}
firstSeen := map[string]int{}
for i, t := range l {
if m, ok := grouped[string(t.TagName)]; !ok {
grouped[string(t.TagName)] = map[string]int{t.ID: 1}
} else {
m[t.ID]++
}
values[t.ID] = t.TagValue
if _, ok := firstSeen[t.ID]; !ok {
firstSeen[t.ID] = i
}
}
tags := Tags{}
for name, counts := range grouped {
idList := make([]string, 0, len(counts))
for tid := range counts {
idList = append(idList, tid)
}
slices.SortFunc(idList, func(a, b string) int {
return cmp.Or(
cmp.Compare(counts[b], counts[a]),
cmp.Compare(firstSeen[a], firstSeen[b]),
)
})
tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] })
}
return tags
}
func (t Tag) String() string {
return fmt.Sprintf("%s=%s", t.TagName, t.TagValue)
}
func NewTag(name TagName, value string) Tag {
name = name.ToLower()
hashID := tagID(name, value)
return Tag{
ID: hashID,
TagName: name,
TagValue: value,
}
}
func tagID(name TagName, value string) string {
return id.NewTagID(string(name), value)
}
type RawTags map[string][]string
type Tags map[TagName][]string
func (t Tags) Values(name TagName) []string {
return t[name]
}
func (t Tags) IDs() []string {
var ids []string
for name, tag := range t {
name = name.ToLower()
for _, v := range tag {
ids = append(ids, tagID(name, v))
}
}
return ids
}
func (t Tags) Flatten(name TagName) TagList {
var tags TagList
for _, v := range t[name] {
tags = append(tags, NewTag(name, v))
}
return tags
}
func (t Tags) FlattenAll() TagList {
var tags TagList
for name, values := range t {
for _, v := range values {
tags = append(tags, NewTag(name, v))
}
}
return tags
}
func (t Tags) Sort() {
for _, values := range t {
slices.Sort(values)
}
}
func (t Tags) Hash() []byte {
if len(t) == 0 {
return nil
}
ids := t.IDs()
slices.Sort(ids)
sum := xxh3.New()
sum.Write([]byte(strings.Join(ids, "|")))
return sum.Sum(nil)
}
func (t Tags) ToGenres() (string, Genres) {
values := t.Values("genre")
if len(values) == 0 {
return "", nil
}
genres := slice.Map(values, func(g string) Genre {
t := NewTag("genre", g)
return Genre{ID: t.ID, Name: g}
})
return genres[0].Name, genres
}
// Merge merges the tags from another Tags object into this one, removing any duplicates
func (t Tags) Merge(tags Tags) {
for name, values := range tags {
for _, v := range values {
t.Add(name, v)
}
}
}
func (t Tags) Add(name TagName, v string) {
if slices.Contains(t[name], v) {
return
}
t[name] = append(t[name], v)
}
type TagRepository interface {
Add(libraryID int, tags ...Tag) error
UpdateCounts() error
GetAll(name TagName, options ...QueryOptions) (TagList, error)
}
type TagName string
func (t TagName) ToLower() TagName {
return TagName(strings.ToLower(string(t)))
}
func (t TagName) String() string {
return string(t)
}
// Tag names, as defined in the mappings.yaml file
const (
TagAlbum TagName = "album"
TagTitle TagName = "title"
TagTrackNumber TagName = "track"
TagDiscNumber TagName = "disc"
TagTotalTracks TagName = "tracktotal"
TagTotalDiscs TagName = "disctotal"
TagDiscSubtitle TagName = "discsubtitle"
TagSubtitle TagName = "subtitle"
TagGenre TagName = "genre"
TagMood TagName = "mood"
TagComment TagName = "comment"
TagAlbumSort TagName = "albumsort"
TagAlbumVersion TagName = "albumversion"
TagTitleSort TagName = "titlesort"
TagCompilation TagName = "compilation"
TagGrouping TagName = "grouping"
TagLyrics TagName = "lyrics"
TagRecordLabel TagName = "recordlabel"
TagReleaseType TagName = "releasetype"
TagReleaseCountry TagName = "releasecountry"
TagMedia TagName = "media"
TagCatalogNumber TagName = "catalognumber"
TagISRC TagName = "isrc"
TagBPM TagName = "bpm"
TagExplicitStatus TagName = "explicitstatus"
TagWork TagName = "work"
TagMovementName TagName = "movementname"
TagMovementNumber TagName = "movement"
TagMovementTotal TagName = "movementtotal"
// Dates and years
TagOriginalDate TagName = "originaldate"
TagReleaseDate TagName = "releasedate"
TagRecordingDate TagName = "recordingdate"
// Artists and roles
TagAlbumArtist TagName = "albumartist"
TagAlbumArtists TagName = "albumartists"
TagAlbumArtistSort TagName = "albumartistsort"
TagAlbumArtistsSort TagName = "albumartistssort"
TagTrackArtist TagName = "artist"
TagTrackArtists TagName = "artists"
TagTrackArtistSort TagName = "artistsort"
TagTrackArtistsSort TagName = "artistssort"
TagComposer TagName = "composer"
TagComposerSort TagName = "composersort"
TagLyricist TagName = "lyricist"
TagLyricistSort TagName = "lyricistsort"
TagDirector TagName = "director"
TagProducer TagName = "producer"
TagEngineer TagName = "engineer"
TagMixer TagName = "mixer"
TagRemixer TagName = "remixer"
TagDJMixer TagName = "djmixer"
TagConductor TagName = "conductor"
TagArranger TagName = "arranger"
TagPerformer TagName = "performer"
// ReplayGain
TagReplayGainAlbumGain TagName = "replaygain_album_gain"
TagReplayGainAlbumPeak TagName = "replaygain_album_peak"
TagReplayGainTrackGain TagName = "replaygain_track_gain"
TagReplayGainTrackPeak TagName = "replaygain_track_peak"
TagR128AlbumGain TagName = "r128_album_gain"
TagR128TrackGain TagName = "r128_track_gain"
// MusicBrainz
TagMusicBrainzArtistID TagName = "musicbrainz_artistid"
TagMusicBrainzRecordingID TagName = "musicbrainz_recordingid"
TagMusicBrainzTrackID TagName = "musicbrainz_trackid"
TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid"
TagMusicBrainzAlbumID TagName = "musicbrainz_albumid"
TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid"
TagMusicBrainzWorkID TagName = "musicbrainz_workid"
TagMusicBrainzComposerID TagName = "musicbrainz_composerid"
TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid"
TagMusicBrainzDirectorID TagName = "musicbrainz_directorid"
TagMusicBrainzProducerID TagName = "musicbrainz_producerid"
TagMusicBrainzEngineerID TagName = "musicbrainz_engineerid"
TagMusicBrainzMixerID TagName = "musicbrainz_mixerid"
TagMusicBrainzRemixerID TagName = "musicbrainz_remixerid"
TagMusicBrainzDJMixerID TagName = "musicbrainz_djmixerid"
TagMusicBrainzConductorID TagName = "musicbrainz_conductorid"
TagMusicBrainzArrangerID TagName = "musicbrainz_arrangerid"
TagMusicBrainzPerformerID TagName = "musicbrainz_performerid"
)