Moved logic of collapsing songs into albums to model package

(it should really be called domain.... maybe will rename it later)
This commit is contained in:
Deluan 2022-12-19 11:00:20 -05:00 committed by Deluan Quintão
parent e03ccb3166
commit 28e7371d93
13 changed files with 507 additions and 26 deletions

View File

@ -59,6 +59,8 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second
DefaultScannerExtractor = "taglib"
Zwsp = string('\u200b')
)
// Cache options

View File

@ -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)

View File

@ -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)

View File

@ -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"))
})
})
})

219
model/mediafile_test.go Normal file
View File

@ -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{}
}

View File

@ -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 {

View File

@ -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))

View File

@ -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)

View File

@ -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)
}

View File

@ -1,4 +1,4 @@
package math2
package number
import "golang.org/x/exp/constraints"

View File

@ -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))
})
})

35
utils/slice/slice.go Normal file
View File

@ -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
}

43
utils/slice/slice_test.go Normal file
View File

@ -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"))
})
})