From 1e05d88a8d74dff067ec417cd9322aac4224d6da Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Sun, 7 Apr 2024 20:27:06 -0300 Subject: [PATCH 1/4] misc: Move Jellyfin Artist iterator to separate file --- .../mediaprovider/jellyfin/artistiterator.go | 55 +++++++++++++++++++ backend/mediaprovider/jellyfin/iterators.go | 45 --------------- 2 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 backend/mediaprovider/jellyfin/artistiterator.go diff --git a/backend/mediaprovider/jellyfin/artistiterator.go b/backend/mediaprovider/jellyfin/artistiterator.go new file mode 100644 index 0000000..75481a1 --- /dev/null +++ b/backend/mediaprovider/jellyfin/artistiterator.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "github.com/dweymouth/go-jellyfin" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" + "github.com/dweymouth/supersonic/sharedutil" +) + +const ( + ArtistSortNameAZ string = "Name (A-Z)" +) + +func (j *jellyfinMediaProvider) ArtistSortOrders() []string { + return []string{ + ArtistSortNameAZ, + } +} + +func (j *jellyfinMediaProvider) IterateArtists(sortOrder string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { + var jfSort jellyfin.Sort + + if sortOrder == "" { + sortOrder = ArtistSortNameAZ // default + } + switch sortOrder { + case ArtistSortNameAZ: + jfSort.Field = jellyfin.SortByName + jfSort.Mode = jellyfin.SortAsc + } + + fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { + ar, err := j.client.GetAlbumArtists(jellyfin.QueryOpts{ + Sort: jfSort, + Paging: jellyfin.Paging{StartIndex: offs, Limit: limit}, + }) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(ar, toArtist), nil + } + + return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) +} + +func (j *jellyfinMediaProvider) SearchArtists(searchQuery string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { + fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { + sr, err := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{StartIndex: offs, Limit: limit}) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(sr.Artists, toArtist), nil + } + return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) +} diff --git a/backend/mediaprovider/jellyfin/iterators.go b/backend/mediaprovider/jellyfin/iterators.go index 33d38f1..9b1d1c1 100644 --- a/backend/mediaprovider/jellyfin/iterators.go +++ b/backend/mediaprovider/jellyfin/iterators.go @@ -16,8 +16,6 @@ const ( AlbumSortArtistAZ string = "Artist (A-Z)" AlbumSortYearAscending string = "Year (ascending)" AlbumSortYearDescending string = "Year (descending)" - - ArtistSortNameAZ string = "Name (A-Z)" ) func (j *jellyfinMediaProvider) AlbumSortOrders() []string { @@ -31,12 +29,6 @@ func (j *jellyfinMediaProvider) AlbumSortOrders() []string { } } -func (j *jellyfinMediaProvider) ArtistSortOrders() []string { - return []string{ - ArtistSortNameAZ, - } -} - func (j *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { var jfSort jellyfin.Sort switch sortOrder { @@ -124,43 +116,6 @@ func (j *jellyfinMediaProvider) IterateTracks(searchQuery string) mediaprovider. return helpers.NewTrackIterator(fetcher, j.prefetchCoverCB) } -func (j *jellyfinMediaProvider) IterateArtists(sortOrder string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { - var jfSort jellyfin.Sort - - if sortOrder == "" { - sortOrder = ArtistSortNameAZ // default - } - switch sortOrder { - case ArtistSortNameAZ: - jfSort.Field = jellyfin.SortByName - jfSort.Mode = jellyfin.SortAsc - } - - fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { - ar, err := j.client.GetAlbumArtists(jellyfin.QueryOpts{ - Sort: jfSort, - Paging: jellyfin.Paging{StartIndex: offs, Limit: limit}, - }) - if err != nil { - return nil, err - } - return sharedutil.MapSlice(ar, toArtist), nil - } - - return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) -} - -func (j *jellyfinMediaProvider) SearchArtists(searchQuery string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { - fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { - sr, err := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{StartIndex: offs, Limit: limit}) - if err != nil { - return nil, err - } - return sharedutil.MapSlice(sr.Artists, toArtist), nil - } - return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) -} - // Creates the Jellyfin filter to implement the given mediaprovider filter, // and returns a modified mediaprovider filter, with now-unneeded fields zeroed out. func jfFilterFromFilter(filter mediaprovider.AlbumFilter) (jellyfin.Filter, mediaprovider.AlbumFilter) { From 14bfaac7a2d2b9d45811d6fbda2cb3773d6aba84 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Sat, 13 Apr 2024 21:04:51 -0300 Subject: [PATCH 2/4] feat: Add sorting options for Artists Include both `Album Count` and `Random` sortings for the Artists page. This change also fixes the Artist search feature in Jellyfin, which was broken because of an open issue on Jellyfin [1]. The current workaround is to do the filtering locally after receiving all results from Jellyfin API. Also, as the Jellyfin API doesn't include a sorting option for `AlbumCount`, we need to disable pagination when that option is selected, to receive all artists, and run the sorting locally. [1] Related issue: https://github.com/jellyfin/jellyfin/issues/8222 --- .../mediaprovider/jellyfin/artistiterator.go | 101 +++++++++++++++--- backend/mediaprovider/mediaprovider.go | 20 +++- .../mediaprovider/subsonic/artistiterator.go | 29 ++++- 3 files changed, 130 insertions(+), 20 deletions(-) diff --git a/backend/mediaprovider/jellyfin/artistiterator.go b/backend/mediaprovider/jellyfin/artistiterator.go index 75481a1..53b2a3d 100644 --- a/backend/mediaprovider/jellyfin/artistiterator.go +++ b/backend/mediaprovider/jellyfin/artistiterator.go @@ -1,6 +1,8 @@ package jellyfin import ( + "slices" + "github.com/dweymouth/go-jellyfin" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" @@ -8,48 +10,117 @@ import ( ) const ( - ArtistSortNameAZ string = "Name (A-Z)" + ArtistSortAlbumCount string = "Album Count" + ArtistSortNameAZ string = "Name (A-Z)" + ArtistSortRandom string = "Random" ) func (j *jellyfinMediaProvider) ArtistSortOrders() []string { return []string{ + ArtistSortAlbumCount, ArtistSortNameAZ, + ArtistSortRandom, } } func (j *jellyfinMediaProvider) IterateArtists(sortOrder string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { var jfSort jellyfin.Sort + var disablePagination bool + var sortFn func([]*jellyfin.Artist) []*jellyfin.Artist if sortOrder == "" { sortOrder = ArtistSortNameAZ // default } switch sortOrder { + case ArtistSortAlbumCount: + // Pagination needs to be disabled, to retrieve all results in a single request, and correctly sort them. + disablePagination = true + sortFn = func(artists []*jellyfin.Artist) []*jellyfin.Artist { + slices.SortStableFunc(artists, func(a, b *jellyfin.Artist) int { + return b.AlbumCount - a.AlbumCount + }) + return artists + } case ArtistSortNameAZ: jfSort.Field = jellyfin.SortByName jfSort.Mode = jellyfin.SortAsc + case ArtistSortRandom: + jfSort.Field = jellyfin.SortByRandom } - fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { - ar, err := j.client.GetAlbumArtists(jellyfin.QueryOpts{ - Sort: jfSort, - Paging: jellyfin.Paging{StartIndex: offs, Limit: limit}, - }) - if err != nil { - return nil, err - } - return sharedutil.MapSlice(ar, toArtist), nil - } + fetcher := makeArtistFetchFn( + func(offs, limit int) ([]*jellyfin.Artist, error) { + if disablePagination && offs > 0 { + return nil, nil + } + var paging jellyfin.Paging + if !disablePagination { + paging = jellyfin.Paging{StartIndex: offs, Limit: limit} + } + return j.client.GetAlbumArtists(jellyfin.QueryOpts{ + Sort: jfSort, + Paging: paging, + }) + }, + sortFn, + ) return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) } func (j *jellyfinMediaProvider) SearchArtists(searchQuery string, filter mediaprovider.ArtistFilter) mediaprovider.ArtistIterator { - fetcher := func(offs, limit int) ([]*mediaprovider.Artist, error) { - sr, err := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{StartIndex: offs, Limit: limit}) + // TODO: Jellyfin API is not returning search results for artists. + // Uncomment the following code once the issue is resolved. + // Related issue: https://github.com/jellyfin/jellyfin/issues/8222 + // fetcher := makeArtistFetchFn( + // func(offs, limit int) ([]*jellyfin.Artist, error) { + // log.Printf("Searching for artists: %s", searchQuery) + // sr, err := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{StartIndex: offs, Limit: limit}) + // if err != nil { + // return nil, err + // } + // log.Printf("Found %d artists", len(sr.Artists)) + // for _, a := range sr.Artists { + // log.Printf("Artist: %s", a.Name) + // } + // return sr.Artists, nil + // }, + // nil, + // ) + // return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) + + modifiedFilter := filter.Clone() + modifiedOptions := modifiedFilter.Options() + modifiedOptions.SearchQuery = searchQuery + modifiedFilter.SetOptions(modifiedOptions) + + fetcher := makeArtistFetchFn( + func(offs, limit int) ([]*jellyfin.Artist, error) { + return j.client.GetAlbumArtists(jellyfin.QueryOpts{ + Sort: jellyfin.Sort{ + Field: jellyfin.SortByName, + Mode: jellyfin.SortAsc, + }, + Paging: jellyfin.Paging{StartIndex: offs, Limit: limit}, + }) + }, + nil, + ) + return helpers.NewArtistIterator(fetcher, modifiedFilter, j.prefetchCoverCB) +} + +func makeArtistFetchFn( + fetchFn func(offset, limit int) ([]*jellyfin.Artist, error), + sortFn func([]*jellyfin.Artist) []*jellyfin.Artist, +) helpers.ArtistFetchFn { + return func(offset, limit int) ([]*mediaprovider.Artist, error) { + ar, err := fetchFn(offset, limit) if err != nil { return nil, err } - return sharedutil.MapSlice(sr.Artists, toArtist), nil + if sortFn != nil { + ar = sortFn(ar) + } + return sharedutil.MapSlice(ar, toArtist), nil } - return helpers.NewArtistIterator(fetcher, filter, j.prefetchCoverCB) } diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index f4bc92e..cc04d2c 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -5,6 +5,8 @@ import ( "io" "net/url" "strings" + + "github.com/deluan/sanitize" ) type MediaIterator[M any] interface { @@ -96,11 +98,15 @@ func (f albumFilter) Matches(album *Album) bool { type ArtistFilter = MediaFilter[Artist, ArtistFilterOptions] -type ArtistFilterOptions struct{} +type ArtistFilterOptions struct { + SearchQuery string +} // Clone returns a deep copy of the filter options func (o ArtistFilterOptions) Clone() ArtistFilterOptions { - return ArtistFilterOptions{} + return ArtistFilterOptions{ + SearchQuery: o.SearchQuery, + } } type artistFilter struct { @@ -126,14 +132,20 @@ func (a artistFilter) Clone() ArtistFilter { // Returns true if the filter is the nil filter - i.e. matches everything func (a artistFilter) IsNil() bool { - return true + return a.options.SearchQuery == "" } func (f artistFilter) Matches(artist *Artist) bool { if artist == nil { return false } - return false + if f.options.SearchQuery != "" && !strings.Contains( + sanitize.Accents(strings.ToLower(artist.Name)), + sanitize.Accents(strings.ToLower(f.options.SearchQuery)), + ) { + return false + } + return true } type RatingFavoriteParameters struct { diff --git a/backend/mediaprovider/subsonic/artistiterator.go b/backend/mediaprovider/subsonic/artistiterator.go index ebe606d..a53b007 100644 --- a/backend/mediaprovider/subsonic/artistiterator.go +++ b/backend/mediaprovider/subsonic/artistiterator.go @@ -2,7 +2,9 @@ package subsonic import ( "log" + "math/rand" "slices" + "time" "golang.org/x/text/collate" "golang.org/x/text/language" @@ -14,12 +16,16 @@ import ( ) const ( - ArtistSortNameAZ string = "Name (A-Z)" + ArtistSortAlbumCount string = "Album Count" + ArtistSortNameAZ string = "Name (A-Z)" + ArtistSortRandom string = "Random" ) func (s *subsonicMediaProvider) ArtistSortOrders() []string { return []string{ + ArtistSortAlbumCount, ArtistSortNameAZ, + ArtistSortRandom, } } @@ -35,6 +41,16 @@ func (s *subsonicMediaProvider) IterateArtists(sortOrder string, filter mediapro sortOrder = ArtistSortNameAZ // default } switch sortOrder { + case ArtistSortAlbumCount: + return s.baseArtistIterFromSimpleSortOrder( + func(artists []*subsonic.ArtistID3) []*subsonic.ArtistID3 { + slices.SortStableFunc(artists, func(a, b *subsonic.ArtistID3) int { + return b.AlbumCount - a.AlbumCount + }) + return artists + }, + filter, + ) case ArtistSortNameAZ: return s.baseArtistIterFromSimpleSortOrder( func(artists []*subsonic.ArtistID3) []*subsonic.ArtistID3 { @@ -46,6 +62,17 @@ func (s *subsonicMediaProvider) IterateArtists(sortOrder string, filter mediapro }, filter, ) + case ArtistSortRandom: + return s.baseArtistIterFromSimpleSortOrder( + func(artists []*subsonic.ArtistID3) []*subsonic.ArtistID3 { + newArtists := make([]*subsonic.ArtistID3, len(artists)) + copy(newArtists, artists) + r := rand.New(rand.NewSource(time.Now().UnixNano())) + r.Shuffle(len(newArtists), func(i, j int) { newArtists[i], newArtists[j] = newArtists[j], newArtists[i] }) + return newArtists + }, + filter, + ) default: log.Printf("Undefined artist sort order: %s", sortOrder) return nil From 4aa90187fed14f4fcb7b128ca158aae43bc5c958 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 14 Apr 2024 10:08:14 -0700 Subject: [PATCH 3/4] just use rand.Shuffle --- backend/mediaprovider/subsonic/artistiterator.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/mediaprovider/subsonic/artistiterator.go b/backend/mediaprovider/subsonic/artistiterator.go index a53b007..de03618 100644 --- a/backend/mediaprovider/subsonic/artistiterator.go +++ b/backend/mediaprovider/subsonic/artistiterator.go @@ -67,8 +67,7 @@ func (s *subsonicMediaProvider) IterateArtists(sortOrder string, filter mediapro func(artists []*subsonic.ArtistID3) []*subsonic.ArtistID3 { newArtists := make([]*subsonic.ArtistID3, len(artists)) copy(newArtists, artists) - r := rand.New(rand.NewSource(time.Now().UnixNano())) - r.Shuffle(len(newArtists), func(i, j int) { newArtists[i], newArtists[j] = newArtists[j], newArtists[i] }) + rand.Shuffle(len(newArtists), func(i, j int) { newArtists[i], newArtists[j] = newArtists[j], newArtists[i] }) return newArtists }, filter, From 36555fe64f4faf993a4b37149c900734c1b44f84 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 14 Apr 2024 10:11:23 -0700 Subject: [PATCH 4/4] remove now-unneeded time import --- backend/mediaprovider/subsonic/artistiterator.go | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/mediaprovider/subsonic/artistiterator.go b/backend/mediaprovider/subsonic/artistiterator.go index de03618..13d01e7 100644 --- a/backend/mediaprovider/subsonic/artistiterator.go +++ b/backend/mediaprovider/subsonic/artistiterator.go @@ -4,7 +4,6 @@ import ( "log" "math/rand" "slices" - "time" "golang.org/x/text/collate" "golang.org/x/text/language"