mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-21 16:57:14 +02:00
* 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
272 lines
7.2 KiB
Go
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"
|
|
)
|