diff --git a/backend/mediaprovider/jellyfin/artistiterator.go b/backend/mediaprovider/jellyfin/artistiterator.go new file mode 100644 index 0000000..53b2a3d --- /dev/null +++ b/backend/mediaprovider/jellyfin/artistiterator.go @@ -0,0 +1,126 @@ +package jellyfin + +import ( + "slices" + + "github.com/dweymouth/go-jellyfin" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" + "github.com/dweymouth/supersonic/sharedutil" +) + +const ( + 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 := 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 { + // 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 + } + if sortFn != nil { + ar = sortFn(ar) + } + return sharedutil.MapSlice(ar, toArtist), nil + } +} 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) { 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..13d01e7 100644 --- a/backend/mediaprovider/subsonic/artistiterator.go +++ b/backend/mediaprovider/subsonic/artistiterator.go @@ -2,6 +2,7 @@ package subsonic import ( "log" + "math/rand" "slices" "golang.org/x/text/collate" @@ -14,12 +15,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 +40,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 +61,16 @@ 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) + rand.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