mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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.
266 lines
7.1 KiB
Go
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"
|
|
)
|