implement Quick Search for Jellyfin

This commit is contained in:
Drew Weymouth
2023-11-14 09:05:02 -08:00
parent 9e10e2442c
commit 4f9b3a70bf
6 changed files with 222 additions and 76 deletions
+67
View File
@@ -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
})
}
+131 -2
View File
@@ -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
}
+4 -62
View File
@@ -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 {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+17 -9
View File
@@ -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()