From 0439ec695be0670939e4aee8c2dfb4b39f16de5a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 30 Oct 2023 18:18:59 -0700 Subject: [PATCH] add SearchAll function to MediaProvider --- backend/mediaprovider/mediaprovider.go | 2 + backend/mediaprovider/model.go | 42 ++++++ backend/mediaprovider/subsonic/searchall.go | 136 ++++++++++++++++++ .../subsonic/subsonicmediaprovider.go | 26 +++- 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 backend/mediaprovider/subsonic/searchall.go diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 6529074..db30fdb 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -57,6 +57,8 @@ type MediaProvider interface { SearchAlbums(searchQuery string, filter AlbumFilter) AlbumIterator + SearchAll(searchQuery string, maxResults int) ([]*SearchResult, error) + GetRandomTracks(genre string, count int) ([]*Track, error) GetSimilarTracks(artistID string, count int) ([]*Track, error) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 91e5837..bc1ae7d 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -87,3 +87,45 @@ type PlaylistWithTracks struct { Playlist Tracks []*Track } + +type ContentType int + +const ( + ContentTypeAlbum ContentType = iota + ContentTypeArtist + ContentTypeTrack + ContentTypePlaylist + ContentTypeGenre +) + +func (c ContentType) String() string { + switch c { + case ContentTypeAlbum: + return "Album" + case ContentTypeArtist: + return "Artist" + case ContentTypeTrack: + return "Track" + case ContentTypePlaylist: + return "Playlist" + case ContentTypeGenre: + return "Genre" + default: + return "Unknown" + } +} + +type SearchResult struct { + Name string + ID string + CoverID string + Type ContentType + + // for Album / Playlist: track count + // Artist / Genre: album count + // Track: length (seconds) + Size int + + // Unset for ContentTypes Artist, Playlist, and Genre + ArtistName string +} diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go new file mode 100644 index 0000000..edcbc55 --- /dev/null +++ b/backend/mediaprovider/subsonic/searchall.go @@ -0,0 +1,136 @@ +package subsonic + +import ( + "sort" + "strconv" + "strings" + "sync" + + "github.com/dweymouth/go-subsonic/subsonic" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" +) + +func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { + var wg sync.WaitGroup + var err error // only set by Search3 + var result *subsonic.SearchResult3 + var playlists []*subsonic.Playlist + var genres []*subsonic.Genre + + wg.Add(1) + go func() { + count := strconv.Itoa(maxResults / 3) + res, e := s.client.Search3(searchQuery, map[string]string{ + "artistCount": count, + "albumCount": count, + "songCount": count, + }) + if e != nil { + err = e + } else { + result = res + } + wg.Done() + }() + + queryLowerWords := strings.Fields(strings.ToLower(searchQuery)) + + wg.Add(1) + go func() { + p, e := s.client.GetPlaylists(nil) + if e != nil { + playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool { + return allTermsMatch(strings.ToLower(p.Name), queryLowerWords) + }) + } + wg.Done() + }() + + wg.Add(1) + go func() { + g, e := s.client.GetGenres() + if e != nil { + genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool { + return allTermsMatch(strings.ToLower(g.Name), queryLowerWords) + }) + } + wg.Done() + }() + + wg.Wait() + if err != nil { + return nil, err + } + + results := mergeResults(result, playlists, genres) + //rankResults(results, queryLowerWords) // TODO + if len(results) > maxResults { + results = results[:maxResults] + } + return results, nil +} + +// name and terms should be pre-converted to the same case +func allTermsMatch(name string, terms []string) bool { + for _, t := range terms { + if !strings.Contains(name, t) { + return false + } + } + return true +} + +func mergeResults( + searchResult *subsonic.SearchResult3, + matchingPlaylists []*subsonic.Playlist, + matchingGenres []*subsonic.Genre, +) []*mediaprovider.SearchResult { + var results []*mediaprovider.SearchResult + + for _, al := range searchResult.Album { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeAlbum, + Name: al.Name, + ArtistName: getNameString(al.Artist, al.Artists), + Size: al.SongCount, + }) + } + + for _, ar := range searchResult.Artist { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeArtist, + Name: ar.Name, + Size: ar.AlbumCount, + }) + } + + for _, tr := range searchResult.Song { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeTrack, + Name: tr.Title, + ArtistName: getNameString(tr.Artist, tr.Artists), + Size: tr.Duration, + }) + } + + return results +} + +func rankResults(results []*mediaprovider.SearchResult, queryTerms []string) { + // TODO + sort.Slice(results, func(a, b int) bool { + return false + }) +} + +// select Subsonic single-valued name or join OpenSubsonic multi-valued names +func getNameString(singleName string, idNames []subsonic.IDName) string { + if len(idNames) == 0 { + return singleName + } + names := sharedutil.MapSlice(idNames, func(a subsonic.IDName) string { + return a.Name + }) + return strings.Join(names, ", ") +} diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 989d159..e5f52b5 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -14,9 +14,17 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) +const cacheValidDurationSeconds = 60 + type subsonicMediaProvider struct { client *subsonic.Client prefetchCoverCB func(coverArtID string) + + genresCached []*mediaprovider.Genre + genresCachedAt int64 // unix + + playlistsCached []*mediaprovider.Playlist + playlistsCachedAt int64 // unix } func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider { @@ -139,17 +147,23 @@ func (s *subsonicMediaProvider) GetFavorites() (mediaprovider.Favorites, error) } func (s *subsonicMediaProvider) GetGenres() ([]*mediaprovider.Genre, error) { + if s.genresCached != nil && time.Now().Unix()-s.genresCachedAt < cacheValidDurationSeconds { + return s.genresCached, nil + } + g, err := s.client.GetGenres() if err != nil { return nil, err } - return sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre { + s.genresCached = sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre { return &mediaprovider.Genre{ Name: g.Name, AlbumCount: g.AlbumCount, TrackCount: g.SongCount, } - }), nil + }) + s.genresCachedAt = time.Now().Unix() + return s.genresCached, nil } func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) { @@ -165,11 +179,17 @@ func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.P } func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) { + if s.playlistsCached != nil && time.Now().Unix()-s.playlistsCachedAt < cacheValidDurationSeconds { + return s.playlistsCached, nil + } + pl, err := s.client.GetPlaylists(map[string]string{}) if err != nil { return nil, err } - return sharedutil.MapSlice(pl, toPlaylist), nil + s.playlistsCached = sharedutil.MapSlice(pl, toPlaylist) + s.playlistsCachedAt = time.Now().Unix() + return s.playlistsCached, nil } func (s *subsonicMediaProvider) GetRandomTracks(genreName string, count int) ([]*mediaprovider.Track, error) {