From e34497ec24e22e303a1f83a5e3d7d7aa54e90851 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 14 May 2023 17:48:06 -0700 Subject: [PATCH] bunch of work on Subsonic mediaprovider adapter --- backend/mediaprovider/mediaprovider.go | 2 +- backend/mediaprovider/model.go | 3 + .../mediaprovider/subsonic/albumiterator.go | 355 ++++++++++++++++++ .../mediaprovider/subsonic/searchiterbase.go | 33 ++ .../subsonic/subsonicmediaprovider.go | 272 ++++++++++++++ 5 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 backend/mediaprovider/subsonic/albumiterator.go create mode 100644 backend/mediaprovider/subsonic/searchiterbase.go diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 126e7d0..d9244e5 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -68,7 +68,7 @@ type MediaProvider interface { EditPlaylist(id, name, description string, public bool) error - EditPlaylistTracks(id string, trackIDsToAdd, trackIDsToRemove []string) error + EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error ReplacePlaylistTracks(id string, trackIDs []string) error diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 366a691..9aa00f9 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -48,6 +48,8 @@ type Track struct { ParentID string Name string Duration int + TrackNumber int + DiscNumber int Genre string ArtistIDs []string ArtistNames []string @@ -66,6 +68,7 @@ type Playlist struct { Description string Public bool Owner string + TrackCount int } type PlaylistWithTracks struct { diff --git a/backend/mediaprovider/subsonic/albumiterator.go b/backend/mediaprovider/subsonic/albumiterator.go new file mode 100644 index 0000000..4e8a4db --- /dev/null +++ b/backend/mediaprovider/subsonic/albumiterator.go @@ -0,0 +1,355 @@ +package subsonic + +import ( + "log" + "strconv" + "strings" + + "github.com/dweymouth/go-subsonic/subsonic" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" +) + +func filterMatches(f mediaprovider.AlbumFilter, album *subsonic.AlbumID3) bool { + if album == nil { + return false + } + if f.ExcludeFavorited && !album.Starred.IsZero() { + return false + } + if f.ExcludeUnfavorited && album.Starred.IsZero() { + return false + } + if y := album.Year; y < f.MinYear || (f.MaxYear > 0 && y > f.MaxYear) { + return false + } + if len(f.Genres) == 0 { + return true + } + for _, g := range f.Genres { + if strings.EqualFold(g, album.Genre) { + return true + } + } + return false +} + +func filterIsEmpty(f mediaprovider.AlbumFilter) bool { + return !f.ExcludeFavorited && !f.ExcludeUnfavorited && + f.MinYear == 0 && f.MaxYear == 0 && len(f.Genres) == 0 +} + +func (s *subsonicMediaProvider) IterateAlbums(sortOrder, searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { + if searchQuery != "" { + return s.newSearchIter(searchQuery, filter) + } + if sortOrder == "" && len(filter.Genres) == 1 { + return s.newBaseIter("byGenre", filter, map[string]string{"genre": filter.Genres[0]}) + } + if sortOrder == "" && filter.ExcludeUnfavorited { + return s.newBaseIter("starred", filter, make(map[string]string)) + } + switch sortOrder { + case AlbumSortRecentlyAdded: + return s.newBaseIter("newest", filter, make(map[string]string)) + case AlbumSortRecentlyPlayed: + return s.newBaseIter("recent", filter, make(map[string]string)) + case AlbumSortFrequentlyPlayed: + return s.newBaseIter("frequent", filter, make(map[string]string)) + case AlbumSortRandom: + return s.newRandomIter() + case AlbumSortTitleAZ: + return s.newBaseIter("alphabeticalByName", filter, make(map[string]string)) + case AlbumSortArtistAZ: + return s.newBaseIter("alphabeticalByArtist", filter, make(map[string]string)) + case AlbumSortYearAscending: + return s.newBaseIter("byYear", filter, map[string]string{"fromYear": "0", "toYear": "3000"}) + case AlbumSortYearDescending: + return s.newBaseIter("byYear", filter, map[string]string{"fromYear": "3000", "toYear": "0"}) + default: + log.Printf("Undefined album sort order: %s", sortOrder) + return nil + } +} + +type baseIter struct { + listType string + filter mediaprovider.AlbumFilter + serverPos int + s *subsonic.Client + opts map[string]string + prefetched []mediaprovider.Album + prefetchedPos int + done bool +} + +func (s *subsonicMediaProvider) newBaseIter(listType string, filter mediaprovider.AlbumFilter, opts map[string]string) *baseIter { + return &baseIter{ + listType: listType, + filter: filter, + s: s.client, + opts: opts, + } +} + +func (r *baseIter) Next() *mediaprovider.Album { + if r.done { + return nil + } + if r.prefetched != nil && r.prefetchedPos < len(r.prefetched) { + a := r.prefetched[r.prefetchedPos] + r.prefetchedPos++ + return &a + } + r.prefetched = nil + for { // keep fetching until we are done or have mathcing results + r.opts["offset"] = strconv.Itoa(r.serverPos) + albums, err := r.s.GetAlbumList2(r.listType, r.opts) + if err != nil { + log.Printf("error fetching albums: %s", err.Error()) + albums = nil + } + if len(albums) == 0 { + r.done = true + return nil + } + r.serverPos += len(albums) + albums = sharedutil.FilterSlice(albums, func(al *subsonic.AlbumID3) bool { return filterMatches(r.filter, al) }) + r.prefetched = sharedutil.MapSlice(albums, toAlbum) + if len(albums) > 0 { + break + } + } + r.prefetchedPos = 1 + /* + if r.l.PreCacheCoverFn != nil { + for _, album := range r.prefetched { + go r.l.PreCacheCoverFn(album.CoverArt) + } + } + */ + + ret := r.prefetched[0] + return &ret +} + +type searchIter struct { + searchIterBase + + filter mediaprovider.AlbumFilter + prefetched []*subsonic.AlbumID3 + prefetchedPos int + albumIDset map[string]bool + done bool +} + +func (s *subsonicMediaProvider) newSearchIter(query string, filter mediaprovider.AlbumFilter) *searchIter { + return &searchIter{ + searchIterBase: searchIterBase{ + query: query, + s: s.client, + }, + filter: filter, + albumIDset: make(map[string]bool), + } +} + +func (s *searchIter) Next() *mediaprovider.Album { + if s.done { + return nil + } + + // prefetch more search results from server + if s.prefetched == nil { + results := s.searchIterBase.fetchResults() + if results == nil { + s.done = true + s.albumIDset = nil + return nil + } + + // add results from albums search + s.addNewAlbums(results.Album) + s.albumOffset += len(results.Album) + + // add results from artists search + for _, artist := range results.Artist { + artist, err := s.s.GetArtist(artist.ID) + if err != nil || artist == nil { + log.Printf("error fetching artist: %s", err.Error()) + } else { + s.addNewAlbums(artist.Album) + } + } + s.artistOffset += len(results.Artist) + + // add results from songs search + for _, song := range results.Song { + if song.AlbumID == "" { + continue + } + album, err := s.s.GetAlbum(song.AlbumID) + if err != nil || album == nil { + log.Printf("error fetching album: %s", err.Error()) + } else { + s.addNewAlbums([]*subsonic.AlbumID3{album}) + } + } + s.songOffset += len(results.Song) + } + + // return from prefetched results + if len(s.prefetched) > 0 { + a := s.prefetched[s.prefetchedPos] + s.prefetchedPos++ + if s.prefetchedPos == len(s.prefetched) { + s.prefetched = nil + s.prefetchedPos = 0 + } + + al := toAlbum(a) + return &al + } + + return nil +} + +func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) { + for _, album := range al { + if _, have := s.albumIDset[album.ID]; have { + continue + } + if filterMatches(s.filter, album) { + continue + } + s.prefetched = append(s.prefetched, album) + /* + if s.l.PreCacheCoverFn != nil { + go s.l.PreCacheCoverFn(album.CoverArt) + } + */ + s.albumIDset[album.ID] = true + } +} + +type randomIter struct { + albumIDSet map[string]bool + s *subsonic.Client + prefetched []*subsonic.AlbumID3 + prefetchedPos int + // Random iter works in two phases - phase 1 by requesting random + // albums from the server. Since the Subsonic API provides no way + // of paginating a single random sort, we may get albums back twice. + // We use albumIDSet to keep track of which albums have already been returned. + // Once we start getting back too many already-returned albums, + // switch to requesting more albums from a deterministic sort order. + phaseTwo bool + offset int + done bool +} + +func (s *subsonicMediaProvider) newRandomIter() *randomIter { + return &randomIter{ + s: s.client, + albumIDSet: make(map[string]bool), + } +} + +func (r *randomIter) Next() *mediaprovider.Album { + if r.done { + return nil + } + + if r.prefetched == nil { + if r.phaseTwo { + for len(r.prefetched) == 0 { + albums, err := r.s.GetAlbumList2("newest", map[string]string{"size": "20", "offset": strconv.Itoa(r.offset)}) + if err != nil { + log.Println(err) + albums = nil + } + if len(albums) == 0 { + r.done = true + r.albumIDSet = nil + return nil + } + r.offset += len(albums) + for _, album := range albums { + if _, ok := r.albumIDSet[album.ID]; !ok { + r.prefetched = append(r.prefetched, album) + /* + if r.l.PreCacheCoverFn != nil { + go r.l.PreCacheCoverFn(album.CoverArt) + } + */ + r.albumIDSet[album.ID] = true + } + } + } + r.prefetchedPos = 0 + } else { + albums, err := r.s.GetAlbumList2("random", map[string]string{"size": "25"}) + if err != nil { + log.Println(err) + r.done = true + r.albumIDSet = nil + return nil + } + var hitCount int + for _, album := range albums { + if _, ok := r.albumIDSet[album.ID]; !ok { + hitCount++ + r.prefetched = append(r.prefetched, album) + /* + if r.l.PreCacheCoverFn != nil { + go r.l.PreCacheCoverFn(album.CoverArt) + } + */ + r.albumIDSet[album.ID] = true + } + } + if successRatio := float64(hitCount) / float64(25); successRatio < 0.3 { + r.phaseTwo = true + } + } + } + + // return from prefetched results + if len(r.prefetched) > 0 { + a := r.prefetched[r.prefetchedPos] + r.prefetchedPos++ + if r.prefetchedPos == len(r.prefetched) { + r.prefetched = nil + r.prefetchedPos = 0 + } + + al := toAlbum(a) + return &al + } + + return nil +} + +/* +type BatchingIterator struct { + iter AlbumIterator +} + +func NewBatchingIterator(iter AlbumIterator) *BatchingIterator { + return &BatchingIterator{iter} +} + +func (b *BatchingIterator) NextN(n int) []*subsonic.AlbumID3 { + results := make([]*subsonic.AlbumID3, 0, n) + i := 0 + for i < n { + album := b.iter.Next() + if album == nil { + break + } + results = append(results, album) + i++ + } + return results +} +*/ diff --git a/backend/mediaprovider/subsonic/searchiterbase.go b/backend/mediaprovider/subsonic/searchiterbase.go new file mode 100644 index 0000000..b423dda --- /dev/null +++ b/backend/mediaprovider/subsonic/searchiterbase.go @@ -0,0 +1,33 @@ +package subsonic + +import ( + "log" + "strconv" + + "github.com/dweymouth/go-subsonic/subsonic" +) + +type searchIterBase struct { + query string + artistOffset int + albumOffset int + songOffset int + s *subsonic.Client +} + +func (s *searchIterBase) fetchResults() *subsonic.SearchResult3 { + searchOpts := map[string]string{ + "artistOffset": strconv.Itoa(s.artistOffset), + "albumOffset": strconv.Itoa(s.albumOffset), + "songOffset": strconv.Itoa(s.songOffset), + } + results, err := s.s.Search3(s.query, searchOpts) + if err != nil { + log.Println(err) + results = nil + } + if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 { + return nil + } + return results +} diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index e69de29..0023591 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -0,0 +1,272 @@ +package subsonic + +import ( + "image" + "log" + "strconv" + + "github.com/dweymouth/go-subsonic/subsonic" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" +) + +const ( + AlbumSortRecentlyAdded string = "Recently Added" + AlbumSortRecentlyPlayed string = "Recently Played" + AlbumSortFrequentlyPlayed string = "Frequently Played" + AlbumSortRandom string = "Random" + AlbumSortTitleAZ string = "Title (A-Z)" + AlbumSortArtistAZ string = "Artist (A-Z)" + AlbumSortYearAscending string = "Year (ascending)" + AlbumSortYearDescending string = "Year (descending)" +) + +type subsonicMediaProvider struct { + client *subsonic.Client +} + +func SubsonicMediaProvider(subsonicClient *subsonic.Client) *subsonicMediaProvider /*mediaprovider.MediaProvider*/ { + return &subsonicMediaProvider{client: subsonicClient} +} + +func (s *subsonicMediaProvider) AlbumSortOrders() []string { + return []string{ + AlbumSortRecentlyAdded, + AlbumSortRecentlyPlayed, + AlbumSortFrequentlyPlayed, + AlbumSortRandom, + AlbumSortTitleAZ, + AlbumSortArtistAZ, + AlbumSortYearAscending, + AlbumSortYearDescending, + } +} + +func (s *subsonicMediaProvider) CreatePlaylist(name string, trackIDs []string) error { + return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"name": name}) +} + +func (s *subsonicMediaProvider) DeletePlaylist(id string) error { + return s.client.DeletePlaylist(id) +} + +func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, public bool) error { + return s.client.UpdatePlaylist(id, map[string]string{ + "name": name, + "comment": description, + "public": strconv.FormatBool(public), + }) +} + +func (s *subsonicMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error { + return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove) +} + +func (s *subsonicMediaProvider) GetAlbum(albumID string) (*mediaprovider.AlbumWithTracks, error) { + al, err := s.client.GetAlbum(albumID) + if err != nil { + return nil, err + } + return &mediaprovider.AlbumWithTracks{ + Album: mediaprovider.Album{ + ID: al.ID, + Name: al.Name, + ArtistIDs: []string{al.ArtistID}, + CoverArtID: al.CoverArt, + ArtistNames: []string{al.Artist}, + Genres: []string{al.Genre}, + Year: al.Year, + TrackCount: al.SongCount, + Favorite: !al.Starred.IsZero(), + Duration: al.Duration, + }, + Tracks: sharedutil.MapSlice(al.Song, s.toTrack), + }, nil +} + +func (s *subsonicMediaProvider) GetArtist(artistID string) (*mediaprovider.ArtistWithAlbums, error) { + ar, err := s.client.GetArtist(artistID) + if err != nil { + return nil, err + } + return &mediaprovider.ArtistWithAlbums{ + Artist: mediaprovider.Artist{ + ID: ar.ID, + Name: ar.Name, + AlbumCount: ar.AlbumCount, + }, + Albums: sharedutil.MapSlice(ar.Album, toAlbum), + }, nil +} + +func (s *subsonicMediaProvider) GetArtistInfo(artistID string) (*mediaprovider.ArtistInfo, error) { + info, err := s.client.GetArtistInfo(artistID, map[string]string{}) + if err != nil { + return nil, err + } + return &mediaprovider.ArtistInfo{ + Biography: info.Biography, + LastFMUrl: info.LastFmUrl, + ImageURL: info.LargeImageUrl, + SimilarArtists: sharedutil.MapSlice(info.SimilarArtist, toArtist), + }, nil +} + +func (s *subsonicMediaProvider) GetArtists() ([]mediaprovider.Artist, error) { + idxs, err := s.client.GetArtists(map[string]string{}) + if err != nil { + return nil, err + } + var artists []mediaprovider.Artist + for _, idx := range idxs.Index { + for _, ar := range idx.Artist { + artists = append(artists, toArtistFromID3(ar)) + } + } + return artists, nil +} + +func (s *subsonicMediaProvider) GetCoverArt(id string, size int) (image.Image, error) { + return s.client.GetCoverArt(id, map[string]string{"size": strconv.Itoa(size)}) +} + +func (s *subsonicMediaProvider) GetFavorites() (mediaprovider.Favorites, error) { + fav, err := s.client.GetStarred2(map[string]string{}) + if err != nil { + return mediaprovider.Favorites{}, err + } + return mediaprovider.Favorites{ + Albums: sharedutil.MapSlice(fav.Album, toAlbum), + Artists: sharedutil.MapSlice(fav.Artist, toArtistFromID3), + Tracks: sharedutil.MapSlice(fav.Song, s.toTrack), + }, nil +} + +func (s *subsonicMediaProvider) GetGenres() ([]mediaprovider.Genre, error) { + g, err := s.client.GetGenres() + if err != nil { + return nil, err + } + return sharedutil.MapSlice(g, func(g *subsonic.Genre) mediaprovider.Genre { + return mediaprovider.Genre{ + Name: g.Name, + AlbumCount: g.AlbumCount, + TrackCount: g.SongCount, + } + }), nil +} + +func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) { + pl, err := s.client.GetPlaylist(playlistID) + if err != nil { + return nil, err + } + return &mediaprovider.PlaylistWithTracks{ + Playlist: toPlaylist(pl), + Tracks: sharedutil.MapSlice(pl.Entry, s.toTrack), + }, nil +} + +func (s *subsonicMediaProvider) GetPlaylists() ([]mediaprovider.Playlist, error) { + pl, err := s.client.GetPlaylists(map[string]string{}) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(pl, toPlaylist), nil +} + +func (s *subsonicMediaProvider) GetRandomTracks(genreName string, count int) ([]mediaprovider.Track, error) { + opts := map[string]string{"size": strconv.Itoa(count)} + if genreName != "" { + opts["genre"] = genreName + } + tr, err := s.client.GetRandomSongs(opts) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(tr, s.toTrack), nil +} + +func (s *subsonicMediaProvider) GetSimilarTracks(artistID string, count int) ([]mediaprovider.Track, error) { + tr, err := s.client.GetSimilarSongs2(artistID, map[string]string{"count": strconv.Itoa(count)}) + if err != nil { + return nil, err + } + return sharedutil.MapSlice(tr, s.toTrack), nil +} + +func (s *subsonicMediaProvider) toTrack(ch *subsonic.Child) mediaprovider.Track { + if ch == nil { + log.Println("subsonicMediaProvider: toTrack called on nil track") + return mediaprovider.Track{} + } + return mediaprovider.Track{ + ID: ch.ID, + CoverArtID: ch.CoverArt, + ParentID: ch.Parent, + Name: ch.Title, + Duration: ch.Duration, + TrackNumber: ch.Track, + DiscNumber: ch.DiscNumber, + Genre: ch.Genre, + ArtistIDs: []string{ch.ArtistID}, + ArtistNames: []string{ch.Artist}, + Album: ch.Album, + Rating: ch.UserRating, + Favorite: !ch.Starred.IsZero(), + PlayCount: int(ch.PlayCount), + FilePath: ch.Path, + } +} + +func toAlbum(al *subsonic.AlbumID3) mediaprovider.Album { + if al == nil { + log.Println("subsonicMediaProvider: toAlbum called on nil album") + return mediaprovider.Album{} + } + return mediaprovider.Album{ + ID: al.ID, + CoverArtID: al.CoverArt, + Name: al.Name, + Duration: al.Duration, + ArtistIDs: []string{al.ArtistID}, + ArtistNames: []string{al.Artist}, + Year: al.Year, + Genres: []string{al.Genre}, + TrackCount: al.SongCount, + Favorite: !al.Starred.IsZero(), + } +} + +func toArtist(ar *subsonic.Artist) mediaprovider.Artist { + if ar == nil { + log.Println("subsonicMediaProvider: toArtist called on nil artist") + return mediaprovider.Artist{} + } + return mediaprovider.Artist{ + ID: ar.ID, + Name: ar.Name, + } +} + +func toArtistFromID3(ar *subsonic.ArtistID3) mediaprovider.Artist { + if ar == nil { + log.Println("subsonicMediaProvider: toArtistFromID3 called on nil artistID3") + return mediaprovider.Artist{} + } + return mediaprovider.Artist{ + ID: ar.ID, + Name: ar.Name, + AlbumCount: ar.AlbumCount, + } +} + +func toPlaylist(pl *subsonic.Playlist) mediaprovider.Playlist { + return mediaprovider.Playlist{ + Name: pl.Name, + Description: pl.Comment, + Owner: pl.Owner, + Public: pl.Public, + TrackCount: pl.SongCount, + } +}