diff --git a/backend/mediaprovider/helpers/albumiterator.go b/backend/mediaprovider/helpers/iterators.go similarity index 68% rename from backend/mediaprovider/helpers/albumiterator.go rename to backend/mediaprovider/helpers/iterators.go index 4e0873a..77170b6 100644 --- a/backend/mediaprovider/helpers/albumiterator.go +++ b/backend/mediaprovider/helpers/iterators.go @@ -2,33 +2,47 @@ package helpers import ( "log" - "strings" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" ) -type AlbumFetchFn func(offset, limit int) ([]*mediaprovider.Album, error) +type Filter[T any] interface { + IsNil() bool + Matches(*T) bool +} -type baseIter struct { - filter mediaprovider.AlbumFilter - prefetchCB func(string) +type baseIter[T any] struct { + filter Filter[T] + prefetchCB func(*T) serverPos int - fetchFn AlbumFetchFn - prefetched []*mediaprovider.Album + fetcher func(offset, limit int) ([]*T, error) + prefetched []*T prefetchedPos int done bool } -func NewBaseIter(fetchFn AlbumFetchFn, filter mediaprovider.AlbumFilter, cb func(string)) *baseIter { - return &baseIter{ - prefetchCB: cb, +type AlbumFetchFn func(offset, limit int) ([]*mediaprovider.Album, error) + +func NewAlbumIterator(fetchFn AlbumFetchFn, filter mediaprovider.AlbumFilter, cb func(string)) mediaprovider.AlbumIterator { + return &baseIter[mediaprovider.Album]{ + prefetchCB: func(a *mediaprovider.Album) { cb(a.CoverArtID) }, filter: filter, - fetchFn: fetchFn, + fetcher: fetchFn, } } -func (r *baseIter) Next() *mediaprovider.Album { +type TrackFetchFn func(offset, limit int) ([]*mediaprovider.Track, error) + +func NewTrackIterator(fetchFn TrackFetchFn, cb func(string)) mediaprovider.TrackIterator { + return &baseIter[mediaprovider.Track]{ + prefetchCB: func(a *mediaprovider.Track) { cb(a.CoverArtID) }, + filter: nilFilter[mediaprovider.Track]{}, + fetcher: fetchFn, + } +} + +func (r *baseIter[T]) Next() *T { if r.done { return nil } @@ -39,30 +53,30 @@ func (r *baseIter) Next() *mediaprovider.Album { } r.prefetched = nil for { // keep fetching until we are done or have mathcing results - albums, err := r.fetchFn(r.serverPos, 20) + items, err := r.fetcher(r.serverPos, 20) if err != nil { - log.Printf("error fetching albums: %s", err.Error()) - albums = nil + log.Printf("error fetching items: %s", err.Error()) + items = nil } - if len(albums) == 0 { + if len(items) == 0 { r.done = true return nil } - r.serverPos += len(albums) + r.serverPos += len(items) if !r.filter.IsNil() { - albums = sharedutil.FilterSlice(albums, func(al *mediaprovider.Album) bool { - return filterMatches(r.filter, al) + items = sharedutil.FilterSlice(items, func(al *T) bool { + return r.filter.Matches(al) }) } - r.prefetched = albums - if len(albums) > 0 { + r.prefetched = items + if len(items) > 0 { break } } r.prefetchedPos = 1 if r.prefetchCB != nil { for _, album := range r.prefetched { - go r.prefetchCB(album.CoverArtID) + go r.prefetchCB(album) } } return r.prefetched[0] @@ -87,7 +101,7 @@ type randomIter struct { done bool } -func NewRandomIter(deterministicFetcher, randomFetcher AlbumFetchFn, filter mediaprovider.AlbumFilter, prefetchCoverCB func(string)) *randomIter { +func NewRandomAlbumIter(deterministicFetcher, randomFetcher AlbumFetchFn, filter mediaprovider.AlbumFilter, prefetchCoverCB func(string)) *randomIter { return &randomIter{ filter: filter, prefetchCB: prefetchCoverCB, @@ -119,7 +133,7 @@ func (r *randomIter) Next() *mediaprovider.Album { } r.offset += len(albums) for _, album := range albums { - if _, ok := r.albumIDSet[album.ID]; !ok && filterMatches(r.filter, album) { + if _, ok := r.albumIDSet[album.ID]; !ok && r.filter.Matches(album) { r.prefetched = append(r.prefetched, album) if r.prefetchCB != nil { go r.prefetchCB(album.CoverArtID) @@ -142,7 +156,7 @@ func (r *randomIter) Next() *mediaprovider.Album { // by the filter because we need to know when to move to phase two hitCount++ r.albumIDSet[album.ID] = true - if filterMatches(r.filter, album) { + if r.filter.Matches(album) { r.prefetched = append(r.prefetched, album) if r.prefetchCB != nil { go r.prefetchCB(album.CoverArtID) @@ -171,32 +185,8 @@ func (r *randomIter) Next() *mediaprovider.Album { return nil } -func filterMatches(f mediaprovider.AlbumFilter, album *mediaprovider.Album) bool { - if album == nil { - return false - } - if f.ExcludeFavorited && album.Favorite { - return false - } - if f.ExcludeUnfavorited && !album.Favorite { - return false - } - if y := album.Year; y < f.MinYear || (f.MaxYear > 0 && y > f.MaxYear) { - return false - } - if len(f.Genres) == 0 { - return true - } - return genresMatch(f.Genres, album.Genres) -} +type nilFilter[T any] struct{} -func genresMatch(filterGenres, albumGenres []string) bool { - for _, g1 := range filterGenres { - for _, g2 := range albumGenres { - if strings.EqualFold(g1, g2) { - return true - } - } - } - return false -} +func (n nilFilter[T]) IsNil() bool { return true } + +func (n nilFilter[T]) Matches(*T) bool { return true } diff --git a/backend/mediaprovider/jellyfin/albumiterator.go b/backend/mediaprovider/jellyfin/iterators.go similarity index 50% rename from backend/mediaprovider/jellyfin/albumiterator.go rename to backend/mediaprovider/jellyfin/iterators.go index 0eb72a3..60c8a17 100644 --- a/backend/mediaprovider/jellyfin/albumiterator.go +++ b/backend/mediaprovider/jellyfin/iterators.go @@ -1,6 +1,8 @@ package jellyfin import ( + "time" + "github.com/dweymouth/go-jellyfin" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" @@ -32,37 +34,34 @@ func (j *jellyfinMediaProvider) AlbumSortOrders() []string { } func (j *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { - var jfFilt jellyfin.Filter - if filter.ExcludeUnfavorited { - jfFilt.Favorite = true - filter.ExcludeUnfavorited = false - } var jfSort jellyfin.Sort switch sortOrder { case AlbumSortRecentlyAdded: - jfSort.Field = "DateCreated" + jfSort.Field = jellyfin.SortByDateCreated jfSort.Mode = jellyfin.SortDesc case AlbumSortFrequentlyPlayed: - jfSort.Field = "PlayCount" + jfSort.Field = jellyfin.SortByPlayCount jfSort.Mode = jellyfin.SortDesc case AlbumSortRandom: - jfSort.Field = "Random" + jfSort.Field = jellyfin.SortByRandom case AlbumSortArtistAZ: - jfSort.Field = "AlbumArtist" + jfSort.Field = jellyfin.SortByArtist jfSort.Mode = jellyfin.SortAsc case AlbumSortTitleAZ: - jfSort.Field = "SortName" + jfSort.Field = jellyfin.SortByName jfSort.Mode = jellyfin.SortAsc case AlbumSortRecentlyPlayed: - jfSort.Field = "DatePlayed" + jfSort.Field = jellyfin.SortByDatePlayed jfSort.Mode = jellyfin.SortDesc case AlbumSortYearAscending: - jfSort.Field = "ProductionYear" + jfSort.Field = jellyfin.SortByYear jfSort.Mode = jellyfin.SortAsc case AlbumSortYearDescending: - jfSort.Field = "ProductionYear" + jfSort.Field = jellyfin.SortByYear jfSort.Mode = jellyfin.SortDesc } + jfFilt := jfFilterFromFilter(&filter) + fetcher := func(offs, limit int) ([]*mediaprovider.Album, error) { al, err := j.client.GetAlbums(jellyfin.QueryOpts{ Sort: jfSort, @@ -74,6 +73,7 @@ func (j *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprov } return sharedutil.MapSlice(al, toAlbum), nil } + if sortOrder == AlbumSortRandom { determFetcher := func(offs, limit int) ([]*mediaprovider.Album, error) { al, err := j.client.GetAlbums(jellyfin.QueryOpts{ @@ -86,17 +86,65 @@ func (j *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprov } return sharedutil.MapSlice(al, toAlbum), nil } - return helpers.NewRandomIter(determFetcher, fetcher, filter, j.prefetchCoverCB) + return helpers.NewRandomAlbumIter(determFetcher, fetcher, filter, j.prefetchCoverCB) } - return helpers.NewBaseIter(fetcher, filter, j.prefetchCoverCB) + return helpers.NewAlbumIterator(fetcher, filter, j.prefetchCoverCB) } -func (s *jellyfinMediaProvider) SearchAlbums(searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { - return nil - // TODO: unimplemented +func (j *jellyfinMediaProvider) SearchAlbums(searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { + fetcher := func(offs, limit int) ([]*mediaprovider.Album, error) { + sr, err := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{StartIndex: offs, Limit: limit}) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(sr.Albums, toAlbum), nil + } + return helpers.NewAlbumIterator(fetcher, filter, j.prefetchCoverCB) } -func (s *jellyfinMediaProvider) IterateTracks(searchQuery string) mediaprovider.TrackIterator { - return nil - // TODO: unimplemented +func (j *jellyfinMediaProvider) IterateTracks(searchQuery string) mediaprovider.TrackIterator { + var fetcher helpers.TrackFetchFn + if searchQuery == "" { + fetcher = func(offs, limit int) ([]*mediaprovider.Track, error) { + var opts jellyfin.QueryOpts + opts.Paging = jellyfin.Paging{StartIndex: offs, Limit: limit} + s, err := j.client.GetSongs(opts) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(s, toTrack), nil + } + } else { + fetcher = func(offs, limit int) ([]*mediaprovider.Track, error) { + sr, err := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{StartIndex: offs, Limit: limit}) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(sr.Songs, toTrack), nil + } + } + return helpers.NewTrackIterator(fetcher, j.prefetchCoverCB) +} + +// Creates the Jellyfin filter to implement the given mediaprovider filter, +// and zeros out the now-unneeded fields in the mediaprovider filter. +func jfFilterFromFilter(filter *mediaprovider.AlbumFilter) jellyfin.Filter { + var jfFilt jellyfin.Filter + if filter.ExcludeUnfavorited { + jfFilt.Favorite = true + filter.ExcludeUnfavorited = false // Jellyfin will handle this filter + } + if filter.MinYear > 0 && filter.MaxYear > 0 { + jfFilt.YearRange = [2]int{filter.MinYear, filter.MaxYear} + filter.MinYear, filter.MaxYear = 0, 0 + } else if filter.MinYear > 0 { + jfFilt.YearRange = [2]int{filter.MinYear, time.Now().Year()} + filter.MinYear, filter.MaxYear = 0, 0 + } else if filter.MaxYear > 0 { + jfFilt.YearRange = [2]int{1900, filter.MaxYear} + filter.MinYear, filter.MaxYear = 0, 0 + } + jfFilt.Genres = filter.Genres + filter.Genres = nil + return jfFilt } diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index 9cf60cc..5c70ef3 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -149,7 +149,7 @@ func (j *jellyfinMediaProvider) GetTopTracks(artist mediaprovider.Artist, limit func (j *jellyfinMediaProvider) GetRandomTracks(genreName string, limit int) ([]*mediaprovider.Track, error) { var opts jellyfin.QueryOpts opts.Paging.Limit = limit - opts.Filter.Genres = []jellyfin.NameID{{Name: genreName}} + opts.Filter.Genres = []string{genreName} opts.Sort.Field = "Random" tr, err := j.client.GetSongs(opts) if err != nil { @@ -341,13 +341,13 @@ func fillAlbum(a *jellyfin.Album, album *mediaprovider.Album) { } album.ID = a.ID - //album.CoverArtID = a.CoverArt + album.CoverArtID = a.ID album.Name = a.Name album.Duration = int(a.RunTimeTicks / 10_000_000) album.ArtistIDs = artistIDs album.ArtistNames = artistNames album.Year = a.Year - //album.TrackCount = a. + album.TrackCount = a.ChildCount album.Genres = a.Genres album.Favorite = a.UserData.IsFavorite } diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 644511b..ef3e040 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -3,6 +3,7 @@ package mediaprovider import ( "image" "io" + "strings" ) type AlbumFilter struct { @@ -15,12 +16,31 @@ type AlbumFilter struct { } // Returns true if the filter is the nil filter - i.e. matches everything -func (a *AlbumFilter) IsNil() bool { +func (a AlbumFilter) IsNil() bool { return a.MinYear == 0 && a.MaxYear == 0 && len(a.Genres) == 0 && !a.ExcludeFavorited && !a.ExcludeUnfavorited } +func (f AlbumFilter) Matches(album *Album) bool { + if album == nil { + return false + } + if f.ExcludeFavorited && album.Favorite { + return false + } + if f.ExcludeUnfavorited && !album.Favorite { + return false + } + if y := album.Year; y < f.MinYear || (f.MaxYear > 0 && y > f.MaxYear) { + return false + } + if len(f.Genres) == 0 { + return true + } + return genresMatch(f.Genres, album.Genres) +} + type AlbumIterator interface { Next() *Album } @@ -109,3 +129,14 @@ type MediaProvider interface { RescanLibrary() error } + +func genresMatch(filterGenres, albumGenres []string) bool { + for _, g1 := range filterGenres { + for _, g2 := range albumGenres { + if strings.EqualFold(g1, g2) { + return true + } + } + } + return false +} diff --git a/backend/mediaprovider/subsonic/albumiterator.go b/backend/mediaprovider/subsonic/albumiterator.go index 6fa71e1..6818972 100644 --- a/backend/mediaprovider/subsonic/albumiterator.go +++ b/backend/mediaprovider/subsonic/albumiterator.go @@ -71,7 +71,7 @@ func (s *subsonicMediaProvider) IterateAlbums(sortOrder string, filter mediaprov return s.client.GetAlbumList2("byGenre", map[string]string{"genre": genre, "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)}) } - return helpers.NewBaseIter(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) + return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) } if sortOrder == "" && filter.ExcludeUnfavorited { filter.ExcludeUnfavorited = false // we're already filtering by this @@ -98,13 +98,13 @@ func (s *subsonicMediaProvider) IterateAlbums(sortOrder string, filter mediaprov return s.client.GetAlbumList2("byYear", map[string]string{"fromYear": "0", "toYear": "3000", "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)}) } - return helpers.NewBaseIter(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) + return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) case AlbumSortYearDescending: fetchFn := func(offset, limit int) ([]*subsonic.AlbumID3, error) { return s.client.GetAlbumList2("byYear", map[string]string{"fromYear": "3000", "toYear": "0", "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)}) } - return helpers.NewBaseIter(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) + return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB) default: log.Printf("Undefined album sort order: %s", sortOrder) return nil @@ -214,7 +214,7 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) { } func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) mediaprovider.AlbumIterator { - return helpers.NewRandomIter( + return helpers.NewRandomAlbumIter( s.fetchFnFromStandardSort("newest"), makeFetchFn(func(offset, limit int) ([]*subsonic.AlbumID3, error) { return s.client.GetAlbumList2("random", map[string]string{"size": strconv.Itoa(limit)}) @@ -223,7 +223,7 @@ func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, } func (s *subsonicMediaProvider) baseIterFromSimpleSortOrder(sort string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { - return helpers.NewBaseIter(s.fetchFnFromStandardSort(sort), filter, s.prefetchCoverCB) + return helpers.NewAlbumIterator(s.fetchFnFromStandardSort(sort), filter, s.prefetchCoverCB) } func (s *subsonicMediaProvider) fetchFnFromStandardSort(sort string) helpers.AlbumFetchFn { diff --git a/go.mod b/go.mod index 07e794f..9fb895c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( fyne.io/fyne/v2 v2.4.1 github.com/20after4/configdir v0.1.1 github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 - github.com/dweymouth/go-jellyfin v0.0.0-20231112164127-119b53593792 + github.com/dweymouth/go-jellyfin v0.0.0-20231112223445-0872c2ed6b05 github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 github.com/fsnotify/fsnotify v1.6.0 diff --git a/go.sum b/go.sum index 32e8364..ca11726 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+ github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555 h1:8S+d0LuwdTUEipEzFeXp8rNwTQD47dBdDOg9+FI1+Vw= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro= -github.com/dweymouth/go-jellyfin v0.0.0-20231112164127-119b53593792 h1:Be+Y0AnYaSCeCm2+TUjw5TzowttJbsZsd5a7Wn3dqOI= -github.com/dweymouth/go-jellyfin v0.0.0-20231112164127-119b53593792/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= +github.com/dweymouth/go-jellyfin v0.0.0-20231112223445-0872c2ed6b05 h1:0tAegLU7K2X5FxfvEOmSY37M25Q0eURcMu0OvzIWrdY= +github.com/dweymouth/go-jellyfin v0.0.0-20231112223445-0872c2ed6b05/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0= github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 h1:MIH7MAWWPPVRKEKxz+RJubn+ycyQPimHn1Zvoxs1KRI=