diff --git a/backend/mediaprovider/helpers/search.go b/backend/mediaprovider/helpers/search.go new file mode 100644 index 0000000..8fcdc93 --- /dev/null +++ b/backend/mediaprovider/helpers/search.go @@ -0,0 +1,67 @@ +package helpers + +import ( + "sort" + "strings" + + "github.com/deluan/sanitize" + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +// 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 RankSearchResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) { + if len(queryTerms) == 0 || len(results) < 2 { + return + } + + sanitizeMemo := make(map[string]string, len(results)) + sanitized := func(s string) string { + if x, ok := sanitizeMemo[s]; ok { + return x + } + x := strings.ToLower(sanitize.Accents(s)) + sanitizeMemo[s] = x + return x + } + + sort.Slice(results, func(i, j int) bool { + a, b := results[i], results[j] + aName := sanitized(a.Name) + bName := sanitized(b.Name) + + // Compare by entire query + matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery) + if matchesA && !matchesB { + return true // item A has a direct match with the full query and B does not + } else if matchesB && !matchesA { + return false // item B matches but not A + } + + // Compare by search query terms + for _, term := range queryTerms { + firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term) + if firstTermIdxA >= 0 && firstTermIdxB < 0 { + return true // item A has a direct match with the query term and B does not + } else if firstTermIdxB >= 0 && firstTermIdxA < 0 { + return false // item B matches but not A + } + + if firstTermIdxA < firstTermIdxB { + return true // item A matches the query term starting at an earlier position + } else if firstTermIdxB < firstTermIdxA { + return false // item B matches first + } + } + // Defer to item type for priority order + return a.Type < b.Type + }) +} diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go index fc1f92c..09908d2 100644 --- a/backend/mediaprovider/jellyfin/searchall.go +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -1,11 +1,140 @@ package jellyfin import ( - "errors" + "strings" + "sync" + "github.com/deluan/sanitize" + "github.com/dweymouth/go-jellyfin" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" + "github.com/dweymouth/supersonic/sharedutil" ) func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { - return nil, errors.New("unimplemented") + limit := maxResults / 3 + var wg sync.WaitGroup + var albums []*jellyfin.Album + var artists []*jellyfin.Artist + var songs []*jellyfin.Song + var genres []jellyfin.NameID + var playlists []*jellyfin.Playlist + + wg.Add(1) + go func() { + albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit}) + albums = albumResult.Albums + wg.Done() + }() + wg.Add(1) + go func() { + artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit}) + artists = artistResult.Artists + wg.Done() + }() + wg.Add(1) + go func() { + songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit}) + songs = songResult.Songs + wg.Done() + }() + + querySanitized := strings.ToLower(sanitize.Accents(searchQuery)) + queryLowerWords := strings.Fields(querySanitized) + + wg.Add(1) + go func() { + p, e := s.client.GetPlaylists() + if e == nil { + playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool { + return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) + }) + } + wg.Done() + }() + + wg.Add(1) + go func() { + g, e := s.client.GetGenres(jellyfin.Paging{}) + if e == nil { + genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool { + return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) + }) + } + wg.Done() + }() + + wg.Wait() + + results := mergeResults(albums, artists, songs, playlists, genres) + helpers.RankSearchResults(results, searchQuery, queryLowerWords) + + return results, nil +} + +func mergeResults( + albums []*jellyfin.Album, + artists []*jellyfin.Artist, + songs []*jellyfin.Song, + matchingPlaylists []*jellyfin.Playlist, + matchingGenres []jellyfin.NameID, +) []*mediaprovider.SearchResult { + var results []*mediaprovider.SearchResult + + getArtistNames := func(artist jellyfin.NameID) string { + return artist.Name + } + + for _, al := range albums { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeAlbum, + ID: al.ID, + CoverID: al.ID, + Name: al.Name, + ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","), + Size: al.ChildCount, + }) + } + + for _, ar := range artists { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeArtist, + ID: ar.ID, + CoverID: ar.ID, + Name: ar.Name, + Size: ar.AlbumCount, + }) + } + + for _, tr := range songs { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeTrack, + ID: tr.Id, + CoverID: tr.Id, + Name: tr.Name, + ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","), + Size: int(tr.RunTimeTicks / 10_000_000), + }) + } + + for _, pl := range matchingPlaylists { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypePlaylist, + ID: pl.ID, + CoverID: pl.ID, + Name: pl.Name, + Size: pl.SongCount, + }) + } + + for _, g := range matchingGenres { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeGenre, + ID: g.Name, + Name: g.Name, + Size: -1, + }) + } + + return results } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index 79433c2..8c47963 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -1,7 +1,6 @@ package subsonic import ( - "sort" "strconv" "strings" "sync" @@ -9,6 +8,7 @@ import ( "github.com/deluan/sanitize" "github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/mediaprovider/helpers" "github.com/dweymouth/supersonic/sharedutil" ) @@ -43,7 +43,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ p, e := s.client.GetPlaylists(nil) if e == nil { playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool { - return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) + return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) }) } wg.Done() @@ -54,7 +54,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ g, e := s.client.GetGenres() if e == nil { genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool { - return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) + return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) }) } wg.Done() @@ -66,23 +66,13 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ } results := mergeResults(result, playlists, genres) - rankResults(results, querySanitized, queryLowerWords) + helpers.RankSearchResults(results, querySanitized, queryLowerWords) 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, @@ -144,54 +134,6 @@ func mergeResults( return results } -func rankResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) { - if len(queryTerms) == 0 || len(results) < 2 { - return - } - - sanitizeMemo := make(map[string]string, len(results)) - sanitized := func(s string) string { - if x, ok := sanitizeMemo[s]; ok { - return x - } - x := strings.ToLower(sanitize.Accents(s)) - sanitizeMemo[s] = x - return x - } - - sort.Slice(results, func(i, j int) bool { - a, b := results[i], results[j] - aName := sanitized(a.Name) - bName := sanitized(b.Name) - - // Compare by entire query - matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery) - if matchesA && !matchesB { - return true // item A has a direct match with the full query and B does not - } else if matchesB && !matchesA { - return false // item B matches but not A - } - - // Compare by search query terms - for _, term := range queryTerms { - firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term) - if firstTermIdxA >= 0 && firstTermIdxB < 0 { - return true // item A has a direct match with the query term and B does not - } else if firstTermIdxB >= 0 && firstTermIdxA < 0 { - return false // item B matches but not A - } - - if firstTermIdxA < firstTermIdxB { - return true // item A matches the query term starting at an earlier position - } else if firstTermIdxB < firstTermIdxA { - return false // item B matches first - } - } - // Defer to item type for priority order - return a.Type < b.Type - }) -} - // select Subsonic single-valued name or join OpenSubsonic multi-valued names func getNameString(singleName string, idNames []subsonic.IDName) string { if len(idNames) == 0 { diff --git a/go.mod b/go.mod index 721890a..52688f5 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-20231112233951-e56feff35fc6 + github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986 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 3932b71..37255fd 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-20231112233951-e56feff35fc6 h1:lM/wEw/WL9PQHRvBF5FLNNDkbwIU8txThl9Y+Jxv9s4= -github.com/dweymouth/go-jellyfin v0.0.0-20231112233951-e56feff35fc6/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= +github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986 h1:5yZgruDoqH2Z09ZTHJckF7qHCGW2JrgutwyWpsIdpWI= +github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986/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= diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 52ba02d..2200814 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -217,21 +217,29 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { case mediaprovider.ContentTypePlaylist: secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size)) case mediaprovider.ContentTypeGenre: - secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) + if result.Size > 0 { + secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) + } else { + secondaryText = "" + } } q.secondary.Segments = []widget.RichTextSegment{ &widget.TextSegment{ Text: result.Type.String(), Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true}, }, - &widget.TextSegment{ - Text: " · ", - Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true}, - }, - &widget.TextSegment{ - Text: secondaryText, - Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true}, - }, + } + if secondaryText != "" { + q.secondary.Segments = append(q.secondary.Segments, + &widget.TextSegment{ + Text: " · ", + Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true}, + }, + &widget.TextSegment{ + Text: secondaryText, + Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true}, + }, + ) } q.secondary.Refresh()