diff --git a/consts/consts.go b/consts/consts.go index e4a1cbdf5..1e3ae3e2c 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -59,6 +59,8 @@ const ( DefaultHttpClientTimeOut = 10 * time.Second DefaultScannerExtractor = "taglib" + + Zwsp = string('\u200b') ) // Cache options diff --git a/core/external_metadata.go b/core/external_metadata.go index 371b564ff..3678ad38a 100644 --- a/core/external_metadata.go +++ b/core/external_metadata.go @@ -17,7 +17,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/math2" + "github.com/navidrome/navidrome/utils/number" ) const ( @@ -173,7 +173,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in return ctx.Err() } - topCount := math2.Max(count, 20) + topCount := number.Max(count, 20) topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount) if err != nil { log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) diff --git a/model/mediafile.go b/model/mediafile.go index 9dafc01c9..21d8deccf 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,7 +2,14 @@ package model import ( "mime" + "strings" "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/number" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/exp/slices" ) type MediaFile struct { @@ -61,6 +68,107 @@ func (mf *MediaFile) ContentType() string { type MediaFiles []MediaFile +func (mfs MediaFiles) ToAlbum() Album { + a := Album{SongCount: len(mfs)} + var fullText []string + var albumArtistIds []string + var songArtistIds []string + var mbzAlbumIds []string + var comments []string + for _, m := range mfs { + // We assume these attributes are all the same for all songs on an album + a.ID = m.AlbumID + a.Name = m.Album + a.Artist = m.Artist + a.ArtistID = m.ArtistID + a.AlbumArtist = m.AlbumArtist + a.AlbumArtistID = m.AlbumArtistID + a.SortAlbumName = m.SortAlbumName + a.SortArtistName = m.SortArtistName + a.SortAlbumArtistName = m.SortAlbumArtistName + a.OrderAlbumName = m.OrderAlbumName + a.OrderAlbumArtistName = m.OrderAlbumArtistName + a.MbzAlbumArtistID = m.MbzAlbumArtistID + a.MbzAlbumType = m.MbzAlbumType + a.MbzAlbumComment = m.MbzAlbumComment + a.CatalogNum = m.CatalogNum + a.Compilation = m.Compilation + + // Calculated attributes based on aggregations + a.Duration += m.Duration + a.Size += m.Size + if a.MinYear == 0 { + a.MinYear = m.Year + } else if m.Year > 0 { + a.MinYear = number.Min(a.MinYear, m.Year) + } + a.MaxYear = number.Max(m.Year) + a.UpdatedAt = newer(a.UpdatedAt, m.UpdatedAt) + a.CreatedAt = older(a.CreatedAt, m.CreatedAt) + a.Genres = append(a.Genres, m.Genres...) + comments = append(comments, m.Comment) + albumArtistIds = append(albumArtistIds, m.AlbumArtistID) + songArtistIds = append(songArtistIds, m.ArtistID) + mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) + fullText = append(fullText, + m.Album, m.AlbumArtist, m.Artist, + m.SortAlbumName, m.SortAlbumArtistName, m.SortArtistName, + m.DiscSubtitle) + if m.HasCoverArt { + // TODO CoverArtPriority + a.CoverArtId = m.ID + } + } + comments = slices.Compact(comments) + if len(comments) == 1 { + a.Comment = comments[0] + } + a.Genre = slice.MostFrequent(a.Genres).Name + slices.SortFunc(a.Genres, func(a, b Genre) bool { return a.ID < b.ID }) + a.Genres = slices.Compact(a.Genres) + a.FullText = " " + utils.SanitizeStrings(fullText...) + a = fixAlbumArtist(a, albumArtistIds) + songArtistIds = append(songArtistIds, a.AlbumArtistID, a.ArtistID) + slices.Sort(songArtistIds) + a.AllArtistIDs = strings.Join(slices.Compact(songArtistIds), " ") + a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) + return a +} + +func newer(t1, t2 time.Time) time.Time { + if t1.After(t2) { + return t1 + } + return t2 +} + +func older(t1, t2 time.Time) time.Time { + if t1.IsZero() { + return t2 + } + if t1.After(t2) { + return t2 + } + return t1 +} + +func fixAlbumArtist(a Album, albumArtistIds []string) Album { + if !a.Compilation { + if a.AlbumArtistID == "" { + a.AlbumArtistID = a.ArtistID + a.AlbumArtist = a.Artist + } + return a + } + + albumArtistIds = slices.Compact(albumArtistIds) + if len(albumArtistIds) > 1 { + a.AlbumArtist = consts.VariousArtists + a.AlbumArtistID = consts.VariousArtistsID + } + return a +} + type MediaFileRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) diff --git a/model/mediafile_internal_test.go b/model/mediafile_internal_test.go new file mode 100644 index 000000000..2f902f8e7 --- /dev/null +++ b/model/mediafile_internal_test.go @@ -0,0 +1,53 @@ +package model + +import ( + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fixAlbumArtist", func() { + var album Album + BeforeEach(func() { + album = Album{} + }) + Context("Non-Compilations", func() { + BeforeEach(func() { + album.Compilation = false + album.Artist = "Sparks" + album.ArtistID = "ar-123" + }) + It("returns the track artist if no album artist is specified", func() { + al := fixAlbumArtist(album, nil) + Expect(al.AlbumArtistID).To(Equal("ar-123")) + Expect(al.AlbumArtist).To(Equal("Sparks")) + }) + It("returns the album artist if it is specified", func() { + album.AlbumArtist = "Sparks Brothers" + album.AlbumArtistID = "ar-345" + al := fixAlbumArtist(album, nil) + Expect(al.AlbumArtistID).To(Equal("ar-345")) + Expect(al.AlbumArtist).To(Equal("Sparks Brothers")) + }) + }) + Context("Compilations", func() { + BeforeEach(func() { + album.Compilation = true + album.Name = "Sgt. Pepper Knew My Father" + album.AlbumArtistID = "ar-000" + album.AlbumArtist = "The Beatles" + }) + + It("returns VariousArtists if there's more than one album artist", func() { + al := fixAlbumArtist(album, []string{"ar-123", "ar-345"}) + Expect(al.AlbumArtistID).To(Equal(consts.VariousArtistsID)) + Expect(al.AlbumArtist).To(Equal(consts.VariousArtists)) + }) + + It("returns the sole album artist if they are the same", func() { + al := fixAlbumArtist(album, []string{"ar-000", "ar-000"}) + Expect(al.AlbumArtistID).To(Equal("ar-000")) + Expect(al.AlbumArtist).To(Equal("The Beatles")) + }) + }) +}) diff --git a/model/mediafile_test.go b/model/mediafile_test.go new file mode 100644 index 000000000..96f7536a4 --- /dev/null +++ b/model/mediafile_test.go @@ -0,0 +1,219 @@ +package model_test + +import ( + "time" + + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaFiles", func() { + var mfs MediaFiles + + Context("Simple attributes", func() { + BeforeEach(func() { + mfs = MediaFiles{ + { + ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", + SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", + OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName", + MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", + Compilation: false, CatalogNum: "", + }, + { + ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID", + SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", + OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName", + MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", + Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, + }, + } + }) + + It("sets the single values correctly", func() { + album := mfs.ToAlbum() + Expect(album.ID).To(Equal("AlbumID")) + Expect(album.Name).To(Equal("Album")) + Expect(album.Artist).To(Equal("Artist")) + Expect(album.ArtistID).To(Equal("ArtistID")) + Expect(album.AlbumArtist).To(Equal("AlbumArtist")) + Expect(album.AlbumArtistID).To(Equal("AlbumArtistID")) + Expect(album.SortAlbumName).To(Equal("SortAlbumName")) + Expect(album.SortArtistName).To(Equal("SortArtistName")) + Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName")) + Expect(album.OrderAlbumName).To(Equal("OrderAlbumName")) + Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName")) + Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID")) + Expect(album.MbzAlbumType).To(Equal("MbzAlbumType")) + Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment")) + Expect(album.CatalogNum).To(Equal("CatalogNum")) + Expect(album.Compilation).To(BeTrue()) + Expect(album.CoverArtId).To(Equal("2")) + }) + }) + Context("Aggregated attributes", func() { + When("we have only one song", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Duration: 100.2, Size: 1024, Year: 1985, UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")}, + } + }) + It("calculates the aggregates correctly", func() { + album := mfs.ToAlbum() + Expect(album.Duration).To(Equal(float32(100.2))) + Expect(album.Size).To(Equal(int64(1024))) + Expect(album.MinYear).To(Equal(1985)) + Expect(album.MaxYear).To(Equal(1985)) + Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30"))) + Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30"))) + }) + }) + When("we have multiple songs", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Duration: 100.2, Size: 1024, Year: 1985, UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 200.2, Size: 2048, Year: 0, UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 150.6, Size: 1000, Year: 1986, UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")}, + } + }) + It("calculates the aggregates correctly", func() { + album := mfs.ToAlbum() + Expect(album.Duration).To(Equal(float32(451.0))) + Expect(album.Size).To(Equal(int64(4072))) + Expect(album.MinYear).To(Equal(1985)) + Expect(album.MaxYear).To(Equal(1986)) + Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45"))) + Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30"))) + }) + }) + }) + Context("Calculated attributes", func() { + Context("Genres", func() { + When("we have only one Genre", func() { + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}}}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Genre).To(Equal("Rock")) + Expect(album.Genres).To(ConsistOf(Genre{ID: "g1", Name: "Rock"})) + }) + }) + When("we have multiple Genres", func() { + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g2", Name: "Alternative"}}}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Genre).To(Equal("Rock")) + Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g2", Name: "Alternative"}})) + }) + }) + When("we have one predominant Genre", func() { + var album Album + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g2", Name: "Punk"}, {ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}}} + album = mfs.ToAlbum() + }) + It("sets the correct Genre", func() { + Expect(album.Genre).To(Equal("Punk")) + }) + It("removes duplications from Genres", func() { + Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}})) + }) + }) + }) + Context("Comments", func() { + When("we have only one Comment", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}} + }) + It("sets the correct Comment", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(Equal("comment1")) + }) + }) + When("we have multiple equal comments", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}} + }) + It("sets the correct Comment", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(Equal("comment1")) + }) + }) + When("we have different comments", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(BeEmpty()) + }) + }) + }) + Context("AllArtistIds", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {AlbumArtistID: "22", ArtistID: "11"}, + {AlbumArtistID: "22", ArtistID: "33"}, + {AlbumArtistID: "22", ArtistID: "11"}, + } + }) + It("removes duplications", func() { + album := mfs.ToAlbum() + Expect(album.AllArtistIDs).To(Equal("11 22 33")) + }) + }) + Context("FullText", func() { + BeforeEach(func() { + mfs = MediaFiles{ + { + Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist1", DiscSubtitle: "DiscSubtitle1", + SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName1", + }, + { + Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist2", DiscSubtitle: "DiscSubtitle2", + SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName2", + }, + } + }) + It("fills the fullText attribute correctly", func() { + album := mfs.ToAlbum() + Expect(album.FullText).To(Equal(" album1 albumartist1 artist1 artist2 discsubtitle1 discsubtitle2 sortalbumartistname1 sortalbumname1 sortartistname1 sortartistname2")) + }) + }) + Context("MbzAlbumID", func() { + When("we have only one MbzAlbumID", func() { + BeforeEach(func() { + mfs = MediaFiles{{MbzAlbumID: "id1"}} + }) + It("sets the correct MbzAlbumID", func() { + album := mfs.ToAlbum() + Expect(album.MbzAlbumID).To(Equal("id1")) + }) + }) + When("we have multiple MbzAlbumID", func() { + BeforeEach(func() { + mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}} + }) + It("sets the correct MbzAlbumID", func() { + album := mfs.ToAlbum() + Expect(album.MbzAlbumID).To(Equal("id1")) + }) + }) + }) + }) +}) + +func t(v string) time.Time { + var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"} + for _, f := range timeFormats { + t, err := time.ParseInLocation(f, v, time.UTC) + if err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/persistence/album_repository.go b/persistence/album_repository.go index affa62b0a..38f79c2f1 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" ) type albumRepository struct { @@ -150,7 +151,7 @@ func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (mo func (r *albumRepository) Refresh(ids ...string) error { chunks := utils.BreakUpStringSlice(ids, 100) for _, chunk := range chunks { - err := r.refresh(chunk...) + err := r.refresh2(chunk...) if err != nil { return err } @@ -158,7 +159,27 @@ func (r *albumRepository) Refresh(ids ...string) error { return nil } -const zwsp = string('\u200b') +func (r *albumRepository) refresh2(ids ...string) error { + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + mfs, err := mfRepo.GetAll(model.QueryOptions{Filters: Eq{"album_id": ids}}) + if err != nil { + return err + } + if len(mfs) == 0 { + return nil + } + + grouped := slice.Group(mfs, func(m model.MediaFile) string { return m.AlbumID }) + for _, songs := range grouped { + a := model.MediaFiles(songs).ToAlbum() + err := r.Put(&a) + if err != nil { + return err + } + + } + return nil +} type refreshAlbum struct { model.Album @@ -188,7 +209,7 @@ func (r *albumRepository) refresh(ids ...string) error { max(f.updated_at) as max_updated_at, max(f.created_at) as max_created_at, a.id as current_id, - group_concat(f.comment, "`+zwsp+`") as comments, + group_concat(f.comment, "`+consts.Zwsp+`") as comments, group_concat(f.mbz_album_id, ' ') as mbz_album_id, group_concat(f.disc_subtitle, ' ') as disc_subtitles, group_concat(f.artist, ' ') as song_artists, @@ -237,7 +258,7 @@ func (r *albumRepository) refresh(ids ...string) error { al.AlbumArtistID, al.AlbumArtist = getAlbumArtist(al) al.MinYear = getMinYear(al.Years) al.MbzAlbumID = getMostFrequentMbzID(r.ctx, al.MbzAlbumID, r.tableName, al.Name) - al.Comment = getComment(al.Comments, zwsp) + al.Comment = getComment(al.Comments, consts.Zwsp) if al.CurrentId != "" { toUpdate++ } else { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 07bdc25ef..b0c32afb0 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/math2" + "github.com/navidrome/navidrome/utils/number" ) func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { @@ -60,7 +60,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { } opts.Offset = utils.ParamInt(r, "offset", 0) - opts.Max = math2.Min(utils.ParamInt(r, "size", 10), 500) + opts.Max = number.Min(utils.ParamInt(r, "size", 10), 500) albums, err := api.ds.Album(r.Context()).GetAllWithoutGenres(opts) if err != nil { @@ -168,7 +168,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) { - size := math2.Min(utils.ParamInt(r, "size", 10), 500) + size := number.Min(utils.ParamInt(r, "size", 10), 500) genre := utils.ParamString(r, "genre") fromYear := utils.ParamInt(r, "fromYear", 0) toYear := utils.ParamInt(r, "toYear", 0) @@ -186,8 +186,8 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) } func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) { - count := math2.Min(utils.ParamInt(r, "count", 10), 500) - offset := math2.Min(utils.ParamInt(r, "offset", 0), 500) + count := number.Min(utils.ParamInt(r, "count", 10), 500) + offset := number.Min(utils.ParamInt(r, "offset", 0), 500) genre := utils.ParamString(r, "genre") songs, err := api.getSongs(r.Context(), offset, count, filter.SongsByGenre(genre)) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e2dc22e9b..3c1ea4117 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,7 +19,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/math2" + "github.com/navidrome/navidrome/utils/number" ) const Version = "1.16.1" @@ -138,7 +138,7 @@ func (api *Router) routes() http.Handler { }) r.Group(func(r chi.Router) { // configure request throttling - maxRequests := math2.Max(2, runtime.NumCPU()) + maxRequests := number.Max(2, runtime.NumCPU()) r.Use(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout)) hr(r, "getAvatar", api.GetAvatar) hr(r, "getCoverArt", api.GetCoverArt) diff --git a/utils/gravatar/gravatar.go b/utils/gravatar/gravatar.go index b06f79efb..ea18212b9 100644 --- a/utils/gravatar/gravatar.go +++ b/utils/gravatar/gravatar.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/navidrome/navidrome/utils/math2" + "github.com/navidrome/navidrome/utils/number" ) const baseUrl = "https://www.gravatar.com/avatar" @@ -19,7 +19,7 @@ func Url(email string, size int) string { if size < 1 { size = defaultSize } - size = math2.Min(maxSize, size) + size = number.Min(maxSize, size) return fmt.Sprintf("%s/%x?s=%d", baseUrl, hash, size) } diff --git a/utils/math2/math2.go b/utils/number/number.go similarity index 96% rename from utils/math2/math2.go rename to utils/number/number.go index 57f212347..2a5e21224 100644 --- a/utils/math2/math2.go +++ b/utils/number/number.go @@ -1,4 +1,4 @@ -package math2 +package number import "golang.org/x/exp/constraints" diff --git a/utils/math2/math2_test.go b/utils/number/number_test.go similarity index 55% rename from utils/math2/math2_test.go rename to utils/number/number_test.go index ecf18675d..d4c34ae7b 100644 --- a/utils/math2/math2_test.go +++ b/utils/number/number_test.go @@ -1,38 +1,38 @@ -package math2_test +package number_test import ( "testing" - "github.com/navidrome/navidrome/utils/math2" + "github.com/navidrome/navidrome/utils/number" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -func TestMath2(t *testing.T) { +func TestNumber(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Math2 Suite") + RunSpecs(t, "Number Suite") } var _ = Describe("Min", func() { It("returns zero value if no arguments are passed", func() { - Expect(math2.Min[int]()).To(BeZero()) + Expect(number.Min[int]()).To(BeZero()) }) It("returns the smallest int", func() { - Expect(math2.Min(1, 2)).To(Equal(1)) + Expect(number.Min(1, 2)).To(Equal(1)) }) It("returns the smallest float", func() { - Expect(math2.Min(-4.1, -4.2, -4.0)).To(Equal(-4.2)) + Expect(number.Min(-4.1, -4.2, -4.0)).To(Equal(-4.2)) }) }) var _ = Describe("Max", func() { It("returns zero value if no arguments are passed", func() { - Expect(math2.Max[int]()).To(BeZero()) + Expect(number.Max[int]()).To(BeZero()) }) It("returns the biggest int", func() { - Expect(math2.Max(1, 2)).To(Equal(2)) + Expect(number.Max(1, 2)).To(Equal(2)) }) It("returns the biggest float", func() { - Expect(math2.Max(-4.1, -4.2, -4.0)).To(Equal(-4.0)) + Expect(number.Max(-4.1, -4.2, -4.0)).To(Equal(-4.0)) }) }) diff --git a/utils/slice/slice.go b/utils/slice/slice.go new file mode 100644 index 000000000..d7dba0a61 --- /dev/null +++ b/utils/slice/slice.go @@ -0,0 +1,35 @@ +package slice + +func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T { + m := map[K][]T{} + for _, item := range s { + k := keyFunc(item) + m[k] = append(m[k], item) + } + return m +} + +func MostFrequent[T comparable](list []T) T { + if len(list) == 0 { + var zero T + return zero + } + var topItem T + var topCount int + counters := map[T]int{} + + if len(list) == 1 { + topItem = list[0] + } else { + for _, id := range list { + c := counters[id] + 1 + counters[id] = c + if c > topCount { + topItem = id + topCount = c + } + } + } + + return topItem +} diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go new file mode 100644 index 000000000..a9cee7b3a --- /dev/null +++ b/utils/slice/slice_test.go @@ -0,0 +1,43 @@ +package slice_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSlice(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Slice Suite") +} + +var _ = Describe("Group", func() { + It("returns empty map for an empty input", func() { + keyFunc := func(v int) int { return v % 2 } + result := slice.Group([]int{}, keyFunc) + Expect(result).To(BeEmpty()) + }) + + It("groups by the result of the key function", func() { + keyFunc := func(v int) int { return v % 2 } + result := slice.Group([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, keyFunc) + Expect(result).To(HaveLen(2)) + Expect(result[0]).To(ConsistOf(2, 4, 6, 8, 10)) + Expect(result[1]).To(ConsistOf(1, 3, 5, 7, 9, 11)) + }) +}) + +var _ = Describe("MostFrequent", func() { + It("returns zero value if no arguments are passed", func() { + Expect(slice.MostFrequent([]int{})).To(BeZero()) + }) + + It("returns the single item", func() { + Expect(slice.MostFrequent([]string{"123"})).To(Equal("123")) + }) + It("returns the item that appeared more times", func() { + Expect(slice.MostFrequent([]string{"1", "2", "1", "2", "3", "2"})).To(Equal("2")) + }) +})