Skip to content

Commit

Permalink
feat: #52, search by genre
Browse files Browse the repository at this point in the history
  • Loading branch information
xxxserxxx committed Oct 28, 2024
1 parent 1a744b7 commit 1fe546f
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 41 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,16 @@ In any of the columns:
- `Enter` / `a`: Adds the selected item recursively to the queue.
- Left/right arrow keys (``, ``) navigate between the columns
- Up/down arrow keys (``, ``) navigate the selected column list
- `g`: toggle genre search

In the search field:

- `Enter`: Perform the query.
- `Escape`: Escapes into the columns, where the global key bindings work.

Note that the Search page is *not* a browser like the Browser page: it displays the search results returned by the server. Selecting a different artist will not change the album or song search results. OpenSubsonic servers implement the search function differently; in gonic, if you search for "black", you will get artists with "black" in their names in the artists column; albums with "black" in their titles in the albums column; and songs with "black" in their titles in the songs column. Navidrome appears to include all results with "black" anywhere in their IDv3 metadata. Since the API search results filteres these matches into sections -- artists, albums, and songs -- this means that, with Navidrome, you may see albums that don't have "black" in their names; maybe "black" is in their artist title.
Note that the Search page is *not* a browser like the Browser page: it displays the search results returned by the server. Selecting a different artist will not change the album or song search results.

In Genre Search mode, the genres known by the server are displayed in the middle column. Pressing `Enter` on one of these will load all of the songs with that genre in the third column. Searching with the search field will fill the third column with songs whose genres match the search. Searching for a genre by typing it in should return the same songs as selecting it in the middle column. Note that genre searches may (depending on your Subsonic server's search implementation) be case sensitive.

## Advanced Configuration and Features

Expand Down
1 change: 1 addition & 0 deletions help_text.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ artist, album, or song column
Left previous column
Right next column
Enter/a recursively add item to quue
g toggle genre search
/ start search
search field
Enter search for text
Expand Down
5 changes: 0 additions & 5 deletions page_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,6 @@ func (p *PlaylistPage) UpdatePlaylists() {
stop <- true
return
}
if response == nil {
p.logger.Printf("no error from GetPlaylists, but also no response!")
stop <- true
return
}
p.updatingMutex.Lock()
defer p.updatingMutex.Unlock()
p.ui.playlists = response.Playlists.Playlists
Expand Down
161 changes: 126 additions & 35 deletions page_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main

import (
"fmt"
"slices"
"sort"
"strings"

Expand All @@ -24,6 +25,7 @@ type SearchPage struct {
albumList *tview.List
songList *tview.List
searchField *tview.InputField
queryGenre bool

artists []*subsonic.Artist
albums []*subsonic.Album
Expand Down Expand Up @@ -108,12 +110,24 @@ func (ui *Ui) createSearchPage() *SearchPage {
}
return event
case '/':
searchPage.searchField.SetLabel("search:")
searchPage.ui.app.SetFocus(searchPage.searchField)
return nil
case 'g':
if searchPage.queryGenre {
searchPage.albumList.SetTitle(" album matches ")
} else {
searchPage.albumList.SetTitle(" genres ")
searchPage.populateGenres()
searchPage.ui.app.SetFocus(searchPage.albumList)
}
searchPage.queryGenre = !searchPage.queryGenre
return nil
}

return event
})
search := make(chan string, 5)
searchPage.albumList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyLeft:
Expand All @@ -123,26 +137,52 @@ func (ui *Ui) createSearchPage() *SearchPage {
ui.app.SetFocus(searchPage.songList)
return nil
case tcell.KeyEnter:
if len(searchPage.albums) != 0 {
if !searchPage.queryGenre {
idx := searchPage.albumList.GetCurrentItem()
searchPage.addAlbumToQueue(searchPage.albums[idx])
if idx >= 0 && idx < len(searchPage.albums) {
searchPage.addAlbumToQueue(searchPage.albums[idx])
return nil
}
return event
} else {
search <- ""
searchPage.artistList.Clear()
searchPage.artists = make([]*subsonic.Artist, 0)
searchPage.songList.Clear()
searchPage.songs = make([]*subsonic.SubsonicEntity, 0)

idx := searchPage.albumList.GetCurrentItem()
// searchPage.logger.Printf("current item index = %d; albumList len = %d", idx, searchPage.albumList.GetItemCount())
queryStr, _ := searchPage.albumList.GetItemText(idx)
search <- queryStr
return nil
}
return event
}

switch event.Rune() {
case 'a':
if len(searchPage.albums) != 0 {
idx := searchPage.albumList.GetCurrentItem()
searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name)
if searchPage.queryGenre {
return event
}
idx := searchPage.albumList.GetCurrentItem()
if idx >= 0 && idx < len(searchPage.albums) {
searchPage.addAlbumToQueue(searchPage.albums[idx])
return nil
}
return event
case '/':
searchPage.ui.app.SetFocus(searchPage.searchField)
return nil
case 'g':
if searchPage.queryGenre {
searchPage.albumList.SetTitle(" album matches ")
} else {
searchPage.albumList.SetTitle(" genres ")
searchPage.populateGenres()
searchPage.ui.app.SetFocus(searchPage.albumList)
}
searchPage.queryGenre = !searchPage.queryGenre
return nil
}

return event
Expand Down Expand Up @@ -177,11 +217,20 @@ func (ui *Ui) createSearchPage() *SearchPage {
case '/':
searchPage.ui.app.SetFocus(searchPage.searchField)
return nil
case 'g':
if searchPage.queryGenre {
searchPage.albumList.SetTitle(" album matches ")
} else {
searchPage.albumList.SetTitle(" genres ")
searchPage.populateGenres()
searchPage.ui.app.SetFocus(searchPage.albumList)
}
searchPage.queryGenre = !searchPage.queryGenre
return nil
}

return event
})
search := make(chan string, 5)
searchPage.searchField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyUp, tcell.KeyESC:
Expand All @@ -190,8 +239,10 @@ func (ui *Ui) createSearchPage() *SearchPage {
search <- ""
searchPage.artistList.Clear()
searchPage.artists = make([]*subsonic.Artist, 0)
searchPage.albumList.Clear()
searchPage.albums = make([]*subsonic.Album, 0)
if !searchPage.queryGenre {
searchPage.albumList.Clear()
searchPage.albums = make([]*subsonic.Album, 0)
}
searchPage.songList.Clear()
searchPage.songs = make([]*subsonic.SubsonicEntity, 0)

Expand Down Expand Up @@ -226,51 +277,77 @@ func (s *SearchPage) search(search chan string) {
}
case <-more:
}
res, err := s.ui.connection.Search(query, artOff, albOff, songOff)
var res *subsonic.SubsonicResponse
var err error
if s.queryGenre {
res, err = s.ui.connection.GetSongsByGenre(query, songOff, "")
if len(res.SongsByGenre.Song) == 0 {
continue
}
} else {
res, err = s.ui.connection.Search(query, artOff, albOff, songOff)
// Quit searching if there are no more results
if len(res.SearchResults.Artist) == 0 &&
len(res.SearchResults.Album) == 0 &&
len(res.SearchResults.Song) == 0 {
continue
}
}
if err != nil {
s.logger.PrintError("SearchPage.search", err)
return
}
// Quit searching if there are no more results
if len(res.SearchResults.Artist) == 0 &&
len(res.SearchResults.Album) == 0 &&
len(res.SearchResults.Song) == 0 {
continue
}

query = strings.ToLower(query)
s.ui.app.QueueUpdate(func() {
for _, artist := range res.SearchResults.Artist {
if strings.Contains(strings.ToLower(artist.Name), query) {
s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil)
s.artists = append(s.artists, &artist)
if s.queryGenre {
if songOff == 0 {
s.artistList.Box.SetTitle(" artist matches ")
s.albumList.Box.SetTitle(" genres ")
}
}
s.artistList.Box.SetTitle(fmt.Sprintf(" artist matches (%d) ", len(s.artists)))
for _, album := range res.SearchResults.Album {
if strings.Contains(strings.ToLower(album.Name), query) {
s.albumList.AddItem(tview.Escape(album.Name), "", 0, nil)
s.albums = append(s.albums, &album)
}
}
s.albumList.Box.SetTitle(fmt.Sprintf(" album matches (%d) ", len(s.albums)))
for _, song := range res.SearchResults.Song {
if strings.Contains(strings.ToLower(song.Title), query) {
for _, song := range res.SongsByGenre.Song {
s.songList.AddItem(tview.Escape(song.Title), "", 0, nil)
s.songs = append(s.songs, &song)
}
s.songList.Box.SetTitle(fmt.Sprintf(" genre song matches (%d) ", len(s.songs)))
} else {
for _, artist := range res.SearchResults.Artist {
if strings.Contains(strings.ToLower(artist.Name), query) {
s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil)
s.artists = append(s.artists, &artist)
}
}
s.artistList.Box.SetTitle(fmt.Sprintf(" artist matches (%d) ", len(s.artists)))
for _, album := range res.SearchResults.Album {
if strings.Contains(strings.ToLower(album.Name), query) {
s.albumList.AddItem(tview.Escape(album.Name), "", 0, nil)
s.albums = append(s.albums, &album)
}
}
s.albumList.Box.SetTitle(fmt.Sprintf(" album matches (%d) ", len(s.albums)))
for _, song := range res.SearchResults.Song {
if strings.Contains(strings.ToLower(song.Title), query) {
s.songList.AddItem(tview.Escape(song.Title), "", 0, nil)
s.songs = append(s.songs, &song)
}
}
s.songList.Box.SetTitle(fmt.Sprintf(" song matches (%d) ", len(s.songs)))
}
s.songList.Box.SetTitle(fmt.Sprintf(" song matches (%d) ", len(s.songs)))
})

// Only do this the one time, to prevent loops from stealing the user's focus
if artOff == 0 && albOff == 0 && songOff == 0 {
s.aproposFocus()
}

artOff += len(res.SearchResults.Artist)
albOff += len(res.SearchResults.Album)
songOff += len(res.SearchResults.Song)
if !s.queryGenre {
artOff += len(res.SearchResults.Artist)
albOff += len(res.SearchResults.Album)
songOff += len(res.SearchResults.Song)
} else {
songOff += len(res.SongsByGenre.Song)
}
s.ui.app.Draw()
more <- true
}
}
Expand Down Expand Up @@ -339,3 +416,17 @@ func (s *SearchPage) aproposFocus() {
s.ui.app.SetFocus(s.artistList)
}
}

func (s *SearchPage) populateGenres() {
resp, err := s.ui.connection.GetGenres()
if err != nil {
s.logger.PrintError("populateGenres", err)
return
}
slices.SortFunc(resp.Genres.Genres, func(a, b subsonic.GenreEntry) int {
return strings.Compare(a.Name, b.Name)
})
for _, entry := range resp.Genres.Genres {
s.albumList.AddItem(tview.Escape(entry.Name), "", 0, nil)
}
}
39 changes: 39 additions & 0 deletions subsonic/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ type PlayQueue struct {
Entries SubsonicEntities `json:"entry"`
}

type GenreEntries struct {
Genres []GenreEntry `json:"genre"`
}

type GenreEntry struct {
SongCount int `json:"songCount"`
AlbumCount int `json:"albumCount"`
Name string `json:"value"`
}

type Artist struct {
Id string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -278,6 +288,8 @@ type SubsonicResponse struct {
SearchResults SubsonicResults `json:"searchResult3"`
ScanStatus ScanStatus `json:"scanStatus"`
PlayQueue PlayQueue `json:"playQueue"`
Genres GenreEntries `json:"genres"`
SongsByGenre SubsonicSongs `json:"songsByGenre"`
}

type responseWrapper struct {
Expand Down Expand Up @@ -688,3 +700,30 @@ func (connection *SubsonicConnection) LoadPlayQueue() (*SubsonicResponse, error)
requestUrl := fmt.Sprintf("%s/rest/getPlayQueue?%s", connection.Host, query.Encode())
return connection.getResponse("GetPlayQueue", requestUrl)
}

func (connection *SubsonicConnection) GetGenres() (*SubsonicResponse, error) {
query := defaultQuery(connection)
requestUrl := connection.Host + "/rest/getGenres" + "?" + query.Encode()
resp, err := connection.getResponse("GetGenres", requestUrl)
if err != nil {
return resp, err
}
return resp, nil
}

func (connection *SubsonicConnection) GetSongsByGenre(genre string, offset int, musicFolderID string) (*SubsonicResponse, error) {
query := defaultQuery(connection)
query.Add("genre", genre)
if offset != 0 {
query.Add("offset", strconv.Itoa(offset))
}
if musicFolderID != "" {
query.Add("musicFolderId", musicFolderID)
}
requestUrl := connection.Host + "/rest/getSongsByGenre" + "?" + query.Encode()
resp, err := connection.getResponse("GetPlaylists", requestUrl)
if err != nil {
return resp, err
}
return resp, nil
}

0 comments on commit 1fe546f

Please sign in to comment.