navidrome/model/tag.go
Deluan Quintão 659d067aba
fix(archiver): give same-named albums their own folder in artist zips (#6225)
* refactor: add Tags.First and slice.GroupOrdered helpers

Tags.First returns the first value of a tag or an empty string, replacing the inline len-check-then-index pattern in FullTitle, FullAlbumName, Album.FullName and the Subsonic album version mapping.

slice.GroupOrdered is slice.Group returning the groups in first-seen order, for callers that need a deterministic order the map-based Group cannot give.

* fix(archiver): give same-named albums their own folder in artist zips

Artist zips put every album in a folder named after the album, so two albums with the same name (an original and a deluxe edition, or names that only differ in characters the sanitizer replaces) were merged into one folder, with tracks mixed together and duplicate zip entries when file names collided.

The folder is now named after FullAlbumName(), so with AppendAlbumVersion on (the default) the version is part of the name, matching what clients display. Albums whose sanitized names still clash get a " [suffix]" taken from the first field that has a distinct, non-empty value for all of them: album version, year, release type, record label, catalog number, then a short album id. This follows the shape of beets' %aunique{} path function.

Albums are also grouped with slice.GroupOrdered instead of a map, so the zip is deterministic.

* fix(archiver): use the release year to tell same-named albums apart

Taggers often write an edition's date to the Date tag next to an original date, and the scanner then stores the original year in Year and the edition's year in ReleaseYear. Reissues of the same album therefore share Year, so the year disambiguator could not tell them apart and they fell through to the album id suffix. Prefer ReleaseYear and fall back to Year when it is not set.

Found by downloading an artist zip from a live server built from this branch.

* fix(archiver): let one clashing album keep the plain folder name

A disambiguator was only accepted when every clashing album had a non-empty value, so an original and its deluxe edition (with the version not appended to the name) fell through to the album id suffix. Accept a field whose values are distinct across the group even when one of them is empty, as beets' %aunique{} does: that album keeps the plain name, which the suffixed folders cannot clash with. Two or more empty values still count as a tie.

* test(archiver): refactor tests for album naming conventions and query order
2026-09-25 16:19:30 -04:00

272 lines
7.2 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) First(name TagName) string {
if v := t[name]; len(v) > 0 {
return v[0]
}
return ""
}
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"
)